Compare commits

..
101 changed files with 834 additions and 2363 deletions
@@ -3,7 +3,7 @@ import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store'; import { useMapGetter, useStore } from 'dashboard/composables/store';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls'; import { useCallsStore } from 'dashboard/stores/calls';
@@ -34,7 +34,9 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags'); const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const voiceInboxes = computed(() => const voiceInboxes = computed(() =>
(inboxesList.value || []).filter(isVoiceCallEnabled) (inboxesList.value || []).filter(
inbox => inbox.channel_type === INBOX_TYPES.VOICE
)
); );
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0); const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
@@ -8,6 +8,8 @@ import { useEventListener } from '@vueuse/core';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component'; import FileUpload from 'vue-upload-component';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue'; import WhatsAppOptions from './WhatsAppOptions.vue';
import ContentTemplateSelector from './ContentTemplateSelector.vue'; import ContentTemplateSelector from './ContentTemplateSelector.vue';
@@ -28,7 +30,6 @@ const props = defineProps({
isDropdownActive: { type: Boolean, default: false }, isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' }, messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null }, inboxId: { type: Number, default: null },
voiceEnabled: { type: Boolean, default: false },
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -81,9 +82,11 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox; return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
}); });
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
const shouldShowSignatureButton = computed(() => { const shouldShowSignatureButton = computed(() => {
return ( return (
props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
); );
}); });
@@ -108,7 +111,7 @@ watch(
() => props.hasSelectedInbox, () => props.hasSelectedInbox,
newValue => { newValue => {
nextTick(() => { nextTick(() => {
if (newValue && !props.voiceEnabled) setSignature(); if (newValue && !isVoiceInbox.value) setSignature();
}); });
}, },
{ immediate: true } { immediate: true }
@@ -2,7 +2,7 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators'; import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { import {
appendSignature, appendSignature,
removeSignature, removeSignature,
@@ -100,8 +100,6 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || ''); const inboxMedium = computed(() => props.targetInbox?.medium || '');
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
const effectiveChannelType = computed(() => const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value) getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
); );
@@ -444,7 +442,6 @@ useKeyboardEvents({
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp" :is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
:message-templates="whatsappMessageTemplates" :message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType" :channel-type="inboxChannelType"
:voice-enabled="voiceCallEnabled"
:is-loading="isCreating" :is-loading="isCreating"
:disable-send-button="isCreating" :disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox" :has-selected-inbox="!!targetInbox"
@@ -1,5 +1,4 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
export function useChannelIcon(inbox) { export function useChannelIcon(inbox) {
const channelTypeIconMap = { const channelTypeIconMap = {
@@ -15,6 +14,7 @@ export function useChannelIcon(inbox) {
'Channel::Whatsapp': 'i-woot-whatsapp', 'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram', 'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok', 'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-woot-voice',
}; };
const providerIconMap = { const providerIconMap = {
@@ -38,11 +38,6 @@ export function useChannelIcon(inbox) {
icon = 'i-woot-whatsapp'; 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'; return icon ?? 'i-ri-global-fill';
}); });
@@ -19,11 +19,8 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-whatsapp'); expect(icon).toBe('i-woot-whatsapp');
}); });
it('returns correct icon for voice-enabled Twilio channel', () => { it('returns correct icon for Voice channel', () => {
const inbox = { const inbox = { channel_type: 'Channel::Voice' };
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox); const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice'); expect(icon).toBe('i-woot-voice');
}); });
@@ -51,7 +51,6 @@ import {
import { import {
appendSignature, appendSignature,
collapseSelection,
findNodeToInsertImage, findNodeToInsertImage,
getContentNode, getContentNode,
insertAtCursor, insertAtCursor,
@@ -67,7 +66,6 @@ import {
import { import {
hasPressedEnterAndNotCmdOrShift, hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter, hasPressedCommandAndEnter,
isEscape,
} from 'shared/helpers/KeyboardHelpers'; } from 'shared/helpers/KeyboardHelpers';
import { createTypingIndicator } from '@chatwoot/utils'; import { createTypingIndicator } from '@chatwoot/utils';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
@@ -517,9 +515,7 @@ function setMenubarPosition({ selection } = {}) {
function checkSelection(editorState) { function checkSelection(editorState) {
showSelectionMenu.value = false; showSelectionMenu.value = false;
const { selection } = editorState; const hasSelection = editorState.selection.from !== editorState.selection.to;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
const hasSelection = !selection.empty && !selection.node;
if (hasSelection === isTextSelected.value) return; if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection; isTextSelected.value = hasSelection;
@@ -715,17 +711,12 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
} }
function onKeydown(event) { function onKeydown(event) {
if (isEscape(event)) {
collapseSelection(editorView);
return true;
}
if (isEnterToSendEnabled()) { if (isEnterToSendEnabled()) {
handleLineBreakWhenEnterToSendEnabled(event); handleLineBreakWhenEnterToSendEnabled(event);
} }
if (isCmdPlusEnterToSendEnabled()) { if (isCmdPlusEnterToSendEnabled()) {
handleLineBreakWhenCmdAndEnterToSendEnabled(event); handleLineBreakWhenCmdAndEnterToSendEnabled(event);
} }
return false;
} }
function createEditorView() { function createEditorView() {
@@ -753,9 +744,6 @@ function createEditorView() {
blur: () => { blur: () => {
if (props.disabled) return; if (props.disabled) return;
typingIndicator.stop(); typingIndicator.stop();
// PM keeps its selection on blur — clear the menu flags manually.
isTextSelected.value = false;
editorRoot.value?.classList.remove('has-selection');
emit('blur'); emit('blur');
}, },
paste: (view, event) => { paste: (view, event) => {
@@ -17,8 +17,6 @@ import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list'; import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common'; import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import { collapseSelection } from 'dashboard/helper/editorHelper';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
@@ -364,33 +362,19 @@ export default {
onKeyup() { onKeyup() {
this.$emit('keyup'); this.$emit('keyup');
}, },
onKeydown(view, event) { onKeydown() {
this.$emit('keydown'); 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() { 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'); this.$emit('blur');
}, },
onFocus() { onFocus() {
this.$emit('focus'); this.$emit('focus');
}, },
checkSelection(editorState) { checkSelection(editorState) {
const { selection } = editorState; const { from, to } = editorState.selection;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count. // Check if there's a selection (from and to are different)
const hasSelection = !selection.empty && !selection.node; const hasSelection = from !== to;
// If the selection state is the same as the previous state, do nothing // If the selection state is the same as the previous state, do nothing
if (hasSelection === this.isTextSelected) return; if (hasSelection === this.isTextSelected) return;
// Update the selection state // Update the selection state
@@ -47,11 +47,7 @@ const mockStore = createStore({
11: { id: 11, channel_type: INBOX_TYPES.API }, 11: { id: 11, channel_type: INBOX_TYPES.API },
12: { id: 12, channel_type: INBOX_TYPES.SMS }, 12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM }, 13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: { 14: { id: 14, channel_type: INBOX_TYPES.VOICE },
id: 14,
channel_type: INBOX_TYPES.TWILIO,
voice_enabled: true,
},
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK }, 15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
}; };
return inboxes[id] || null; return inboxes[id] || null;
@@ -215,11 +211,11 @@ describe('useInbox', () => {
}); });
expect(wrapper.vm.isAnInstagramChannel).toBe(true); expect(wrapper.vm.isAnInstagramChannel).toBe(true);
// Test Voice (Twilio with voice_enabled) // Test Voice
wrapper = mount(createTestComponent(14), { wrapper = mount(createTestComponent(14), {
global: { plugins: [mockStore] }, global: { plugins: [mockStore] },
}); });
expect(wrapper.vm.voiceCallEnabled).toBe(true); expect(wrapper.vm.isAVoiceChannel).toBe(true);
// Test Tiktok // Test Tiktok
wrapper = mount(createTestComponent(15), { wrapper = mount(createTestComponent(15), {
@@ -278,8 +274,7 @@ describe('useInbox', () => {
'isAnEmailChannel', 'isAnEmailChannel',
'isAnInstagramChannel', 'isAnInstagramChannel',
'isATiktokChannel', 'isATiktokChannel',
'voiceCallEnabled', 'isAVoiceChannel',
'voiceCallProvider',
]; ];
expectedProperties.forEach(prop => { expectedProperties.forEach(prop => {
@@ -1,11 +1,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys'; import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { import { INBOX_TYPES } from 'dashboard/helper/inbox';
INBOX_TYPES,
isVoiceCallEnabled,
getVoiceCallProvider,
} from 'dashboard/helper/inbox';
export const INBOX_FEATURES = { export const INBOX_FEATURES = {
REPLY_TO: 'replyTo', REPLY_TO: 'replyTo',
@@ -138,9 +134,9 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.TIKTOK; return channelType.value === INBOX_TYPES.TIKTOK;
}); });
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value)); const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value)); });
return { return {
inbox, inbox,
@@ -160,7 +156,6 @@ export const useInbox = (inboxId = null) => {
isAnEmailChannel, isAnEmailChannel,
isAnInstagramChannel, isAnInstagramChannel,
isATiktokChannel, isATiktokChannel,
voiceCallEnabled, isAVoiceChannel,
voiceCallProvider,
}; };
}; };
@@ -109,6 +109,11 @@ export const FORMATTING = {
'redo', 'redo',
], ],
}, },
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Tiktok': { 'Channel::Tiktok': {
marks: [], marks: [],
nodes: [], nodes: [],
-1
View File
@@ -36,7 +36,6 @@ export const FEATURE_FLAGS = {
CHATWOOT_V4: 'chatwoot_v4', CHATWOOT_V4: 'chatwoot_v4',
CHANNEL_INSTAGRAM: 'channel_instagram', CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok', CHANNEL_TIKTOK: 'channel_tiktok',
CHANNEL_VOICE: 'channel_voice',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_CUSTOM_TOOLS: 'custom_tools', CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
CAPTAIN_V2: 'captain_integration_v2', CAPTAIN_V2: 'captain_integration_v2',
@@ -2,7 +2,6 @@ import {
messageSchema, messageSchema,
MessageMarkdownTransformer, MessageMarkdownTransformer,
MessageMarkdownSerializer, MessageMarkdownSerializer,
Selection,
} from '@chatwoot/prosemirror-schema'; } from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils'; import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue'; import * as Sentry from '@sentry/vue';
@@ -274,18 +273,6 @@ 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 * Returns a transaction that inserts a node into editor at the given position
* Has an optional param 'content' to check if the * Has an optional param 'content' to check if the
+10 -21
View File
@@ -11,29 +11,9 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms', SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram', INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok', 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 = { export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp', WHATSAPP: 'whatsapp',
SMS: 'sms', SMS: 'sms',
@@ -50,6 +30,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill', [INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill', [INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill', [INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
}; };
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill'; const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -64,6 +45,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram', [INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
[INBOX_TYPES.LINE]: 'i-woot-line', [INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram', [INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.VOICE]: 'i-woot-voice',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok', [INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
}; };
@@ -76,6 +58,7 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
case INBOX_TYPES.TWILIO: case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP: case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || ''; return phoneNumber || '';
case INBOX_TYPES.EMAIL: case INBOX_TYPES.EMAIL:
@@ -114,6 +97,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE: case INBOX_TYPES.LINE:
return 'line'; return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default: default:
return 'chat'; return 'chat';
} }
@@ -156,6 +142,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.TIKTOK: case INBOX_TYPES.TIKTOK:
return 'brand-tiktok'; return 'brand-tiktok';
case INBOX_TYPES.VOICE:
return 'phone';
default: default:
return 'chat'; return 'chat';
} }
@@ -16,7 +16,6 @@ import {
calculateMenuPosition, calculateMenuPosition,
stripUnsupportedFormatting, stripUnsupportedFormatting,
stripInlineBase64Images, stripInlineBase64Images,
collapseSelection,
} from '../editorHelper'; } from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor'; import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema'; import { EditorState } from '@chatwoot/prosemirror-schema';
@@ -455,37 +454,6 @@ 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', () => { describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => { it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0); const result = insertAtCursor(undefined, schema.text('Hello'), 0);
@@ -636,17 +636,7 @@
"WIDGET_BUILDER": "Widget Builder", "WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration", "BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health", "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", "CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features", "WIDGET_FEATURES": "Widget features",
@@ -145,7 +145,6 @@ const openDelete = inbox => {
<ChannelName <ChannelName
:channel-type="inbox.channel_type" :channel-type="inbox.channel_type"
:medium="inbox.medium" :medium="inbox.medium"
:voice-enabled="inbox.voice_enabled"
class="text-body-main text-n-slate-11" class="text-body-main text-n-slate-11"
/> />
</div> </div>
@@ -21,7 +21,6 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue'; import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue'; import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue'; import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue'; import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue'; import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue'; import BotConfiguration from './components/BotConfiguration.vue';
@@ -47,7 +46,6 @@ export default {
BotConfiguration, BotConfiguration,
CollaboratorsPage, CollaboratorsPage,
ConfigurationPage, ConfigurationPage,
VoiceConfigurationPage,
CustomerSatisfactionPage, CustomerSatisfactionPage,
FacebookReauthorize, FacebookReauthorize,
GreetingsEditor, GreetingsEditor,
@@ -171,17 +169,19 @@ export default {
}, },
]; ];
visibleToAllChannelTabs = [ if (!this.isAVoiceChannel) {
...visibleToAllChannelTabs, visibleToAllChannelTabs = [
{ ...visibleToAllChannelTabs,
key: 'business-hours', {
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'), key: 'business-hours',
}, name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
{ },
key: 'csat', {
name: this.$t('INBOX_MGMT.TABS.CSAT'), key: 'csat',
}, name: this.$t('INBOX_MGMT.TABS.CSAT'),
]; },
];
}
if (this.isAWebWidgetInbox) { if (this.isAWebWidgetInbox) {
visibleToAllChannelTabs = [ visibleToAllChannelTabs = [
@@ -197,6 +197,7 @@ export default {
this.isATwilioChannel || this.isATwilioChannel ||
this.isALineChannel || this.isALineChannel ||
this.isAPIInbox || this.isAPIInbox ||
this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) || (this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration || this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox this.isAWebWidgetInbox
@@ -231,24 +232,6 @@ 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; return visibleToAllChannelTabs;
}, },
currentInboxId() { currentInboxId() {
@@ -829,6 +812,7 @@ export default {
</SettingsFieldSection> </SettingsFieldSection>
<SettingsFieldSection <SettingsFieldSection
v-if="!isAVoiceChannel"
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')" :label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')" :help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
> >
@@ -1256,12 +1240,6 @@ export default {
> >
<ConfigurationPage :inbox="inbox" /> <ConfigurationPage :inbox="inbox" />
</div> </div>
<div
v-if="selectedTabKey === 'voice-configuration'"
class="mx-6 max-w-4xl"
>
<VoiceConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'"> <div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" /> <CustomerSatisfactionPage :inbox="inbox" />
</div> </div>
@@ -12,10 +12,6 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
voiceEnabled: {
type: Boolean,
default: false,
},
}); });
const getters = useStoreGetters(); const getters = useStoreGetters();
const { t } = useI18n(); const { t } = useI18n();
@@ -34,6 +30,7 @@ const i18nMap = {
'Channel::Api': 'API', 'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM', 'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK', 'Channel::Tiktok': 'TIKTOK',
'Channel::Voice': 'VOICE',
}; };
const twilioChannelName = () => { const twilioChannelName = () => {
@@ -48,9 +45,6 @@ const readableChannelName = computed(() => {
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API'); return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
} }
if (props.channelType === 'Channel::TwilioSms') { if (props.channelType === 'Channel::TwilioSms') {
if (props.voiceEnabled) {
return t('INBOX_MGMT.CHANNELS.VOICE');
}
return twilioChannelName(); return twilioChannelName();
} }
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`); return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
@@ -208,6 +208,24 @@ export default {
</NextButton> </NextButton>
</SettingsFieldSection> </SettingsFieldSection>
</div> </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"> <div v-else-if="isALineChannel">
<SettingsFieldSection <SettingsFieldSection
@@ -1,156 +0,0 @@
<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,10 +132,8 @@ const headers = computed(() => [
</template> </template>
</BaseTable> </BaseTable>
<div <div class="flex items-center justify-between mt-4">
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">
>
<p class="text-body-main text-n-slate-11 mb-0">
{{ {{
$t('TEAMS_SETTINGS.AGENTS.SELECTED_COUNT', { $t('TEAMS_SETTINGS.AGENTS.SELECTED_COUNT', {
selected: selectedAgents.length, selected: selectedAgents.length,
@@ -88,7 +88,7 @@ export default {
</script> </script>
<template> <template>
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto"> <div class="h-full w-full p-8 col-span-6 overflow-auto">
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents"> <form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
<PageHeader <PageHeader
:header-title="headerTitle" :header-title="headerTitle"
@@ -103,7 +103,7 @@ export default {
</script> </script>
<template> <template>
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto"> <div class="h-full w-full p-8 col-span-6 overflow-auto">
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents"> <form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
<PageHeader <PageHeader
:header-title="headerTitle" :header-title="headerTitle"
@@ -46,8 +46,8 @@
* 2. Nested properties in additional_attributes (browser_language, referer, etc.) * 2. Nested properties in additional_attributes (browser_language, referer, etc.)
* 3. Nested properties in custom_attributes (conversation_type, etc.) * 3. Nested properties in custom_attributes (conversation_type, etc.)
*/ */
import { coerceToDate } from '@chatwoot/utils';
import jsonLogic from 'json-logic-js'; import jsonLogic from 'json-logic-js';
import { coerceToDate } from '@chatwoot/utils';
/** /**
* Gets a value from a conversation based on the attribute key * Gets a value from a conversation based on the attribute key
@@ -121,8 +121,7 @@ const resolveValue = candidate => {
* @returns {Boolean} - Returns true if the values are considered equal according to filtering rules * @returns {Boolean} - Returns true if the values are considered equal according to filtering rules
* *
* This function handles various equality scenarios: * This function handles various equality scenarios:
* 1. When both values are arrays (e.g. labels): matches if any filter value exists in the conversation array * 1. When both values are arrays: checks if all items in filterValue exist in conversationValue
* (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 * 2. When filterValue is an array but conversationValue is not: checks if conversationValue is included in filterValue
* 3. Otherwise: performs strict equality comparison * 3. Otherwise: performs strict equality comparison
*/ */
@@ -132,9 +131,8 @@ const equalTo = (filterValue, conversationValue) => {
if (filterValue === 'all') return true; if (filterValue === 'all') return true;
if (Array.isArray(conversationValue)) { if (Array.isArray(conversationValue)) {
// For array values like labels, match if any filter value is present. // For array values like labels, check if any of the filter values exist in the array
// Mirrors the backend SQL `tag_id IN (...)` (OR semantics). return filterValue.every(val => conversationValue.includes(val));
return filterValue.some(val => conversationValue.includes(val));
} }
if (!Array.isArray(conversationValue)) { if (!Array.isArray(conversationValue)) {
@@ -416,40 +416,6 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true); 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', () => { it('should match conversation with is_present operator for labels', () => {
const conversation = { labels: ['support', 'urgent', 'new'] }; const conversation = { labels: ['support', 'urgent', 'new'] };
const filters = [ const filters = [
+1 -20
View File
@@ -42,19 +42,6 @@ const updateAuthCookie = (cookieContent, baseDomain = '') =>
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 updateCampaignReadStatus = baseDomain => {
const expireBy = addHours(new Date(), 1); const expireBy = addHours(new Date(), 1);
setCookieWithDomain('cw_snooze_campaigns_till', Number(expireBy), { setCookieWithDomain('cw_snooze_campaigns_till', Number(expireBy), {
@@ -106,19 +93,13 @@ export const IFrameHelper = {
getBubbleHolder: () => document.getElementsByClassName('woot--bubble-holder'), getBubbleHolder: () => document.getElementsByClassName('woot--bubble-holder'),
sendMessage: (key, value) => { sendMessage: (key, value) => {
const element = IFrameHelper.getAppFrame(); const element = IFrameHelper.getAppFrame();
const targetOrigin = getTargetOrigin();
if (!targetOrigin) return;
element.contentWindow.postMessage( element.contentWindow.postMessage(
`chatwoot-widget:${JSON.stringify({ event: key, ...value })}`, `chatwoot-widget:${JSON.stringify({ event: key, ...value })}`,
targetOrigin '*'
); );
}, },
initPostMessageCommunication: () => { initPostMessageCommunication: () => {
window.onmessage = e => { window.onmessage = e => {
const expectedOrigin = getTargetOrigin();
if (!expectedOrigin || e.origin !== expectedOrigin) {
return;
}
if ( if (
typeof e.data !== 'string' || typeof e.data !== 'string' ||
e.data.indexOf('chatwoot-widget:') !== 0 e.data.indexOf('chatwoot-widget:') !== 0
+3 -3
View File
@@ -1,4 +1,4 @@
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = { export const INBOX_FEATURES = {
REPLY_TO: 'replyTo', REPLY_TO: 'replyTo',
@@ -59,8 +59,8 @@ export default {
isALineChannel() { isALineChannel() {
return this.channelType === INBOX_TYPES.LINE; return this.channelType === INBOX_TYPES.LINE;
}, },
voiceCallEnabled() { isAVoiceChannel() {
return isVoiceCallEnabled(this.inbox); return this.channelType === INBOX_TYPES.VOICE;
}, },
isAnEmailChannel() { isAnEmailChannel() {
return this.channelType === INBOX_TYPES.EMAIL; return this.channelType === INBOX_TYPES.EMAIL;
+2 -7
View File
@@ -4,7 +4,6 @@
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# account_sid :string not null # account_sid :string not null
# api_key_secret :string
# api_key_sid :string # api_key_sid :string
# auth_token :string not null # auth_token :string not null
# content_templates :jsonb # content_templates :jsonb
@@ -12,8 +11,6 @@
# medium :integer default("sms") # medium :integer default("sms")
# messaging_service_sid :string # messaging_service_sid :string
# phone_number :string # phone_number :string
# twiml_app_sid :string
# voice_enabled :boolean default(FALSE), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :integer not null # account_id :integer not null
@@ -61,6 +58,8 @@ class Channel::TwilioSms < ApplicationRecord
client.messages.create(**params) client.messages.create(**params)
end end
private
def client def client
if api_key_sid.present? if api_key_sid.present?
Twilio::REST::Client.new(api_key_sid, auth_token, account_sid) Twilio::REST::Client.new(api_key_sid, auth_token, account_sid)
@@ -69,8 +68,6 @@ class Channel::TwilioSms < ApplicationRecord
end end
end end
private
def send_message_from def send_message_from
if messaging_service_sid? if messaging_service_sid?
{ messaging_service_sid: messaging_service_sid } { messaging_service_sid: messaging_service_sid }
@@ -79,5 +76,3 @@ class Channel::TwilioSms < ApplicationRecord
end end
end end
end end
Channel::TwilioSms.prepend_mod_with('Channel::TwilioSms')
@@ -1,66 +0,0 @@
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,10 +58,7 @@ class Whatsapp::OneoffCampaignService
return return
end end
processed_template_params = process_liquid_template_params(contact) send_whatsapp_template_message(to: contact.phone_number)
return if processed_template_params.nil?
send_whatsapp_template_message(to: contact.phone_number, template_params: processed_template_params)
end end
def process_audience(audience_labels) def process_audience(audience_labels)
@@ -73,22 +70,10 @@ class Whatsapp::OneoffCampaignService
Rails.logger.info "Campaign #{campaign.id} processing completed" Rails.logger.info "Campaign #{campaign.id} processing completed"
end end
def process_liquid_template_params(contact) def send_whatsapp_template_message(to:)
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( processor = Whatsapp::TemplateProcessorService.new(
channel: channel, channel: channel,
template_params: template_params template_params: campaign.template_params
) )
name, namespace, lang_code, processed_parameters = processor.call name, namespace, lang_code, processed_parameters = processor.call
+4 -10
View File
@@ -72,7 +72,6 @@ if resource.twilio?
if Current.account_user&.administrator? if Current.account_user&.administrator?
json.auth_token resource.channel.try(:auth_token) json.auth_token resource.channel.try(:auth_token)
json.account_sid resource.channel.try(:account_sid) json.account_sid resource.channel.try(:account_sid)
json.api_key_sid resource.channel.try(:api_key_sid)
end end
end end
@@ -132,13 +131,8 @@ if resource.whatsapp?
json.reauthorization_required resource.channel.try(:reauthorization_required?) json.reauthorization_required resource.channel.try(:reauthorization_required?)
end end
## Voice attributes for TwilioSms ## Voice Channel Attributes
if resource.twilio? && resource.channel.respond_to?(:voice_enabled?) if resource.channel_type == 'Channel::Voice'
json.voice_enabled resource.channel.voice_enabled? json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
json.voice_configured resource.channel.try(:twiml_app_sid).present? json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
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 end
+3 -3
View File
@@ -191,10 +191,10 @@
- name: assignment_v2 - name: assignment_v2
display_name: Assignment V2 display_name: Assignment V2
enabled: true enabled: true
- name: captain_document_auto_sync - name: twilio_content_templates
display_name: Captain Document Auto Sync display_name: Twilio Content Templates
enabled: false enabled: false
premium: true deprecated: true
- name: advanced_search - name: advanced_search
display_name: Advanced Search display_name: Advanced Search
enabled: false enabled: false
-5
View File
@@ -40,11 +40,6 @@ Rails.application.reloader.to_prepare do
if File.exist?(schedule_file) && Sidekiq.server? if File.exist?(schedule_file) && Sidekiq.server?
schedule = YAML.load_file(schedule_file) 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 # Cron entries removed from schedule.yml but possibly still in Redis
# with source:'dynamic' (predating the source tag). load_from_hash! # with source:'dynamic' (predating the source tag). load_from_hash!
# only cleans up source:'schedule' entries, so these need explicit removal. # only cleans up source:'schedule' entries, so these need explicit removal.
-3
View File
@@ -379,9 +379,6 @@ en:
limit_exceeded: 'Document limit exceeded' limit_exceeded: 'Document limit exceeded'
pdf_format_error: 'must be a PDF file' pdf_format_error: 'must be a PDF file'
pdf_size_error: 'must be less than 10MB' 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_failed: 'Failed to upload PDF to OpenAI'
pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}' pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}' pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
+1 -3
View File
@@ -77,9 +77,7 @@ Rails.application.routes.draw do
resources :custom_tools do resources :custom_tools do
post :test, on: :collection post :test, on: :collection
end end
resources :documents, only: [:index, :show, :create, :destroy] do resources :documents, only: [:index, :show, :create, :destroy]
post :sync, on: :member
end
resource :tasks, only: [], controller: 'tasks' do resource :tasks, only: [], controller: 'tasks' do
post :rewrite post :rewrite
post :summarize post :summarize
@@ -1,7 +0,0 @@
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
@@ -1,20 +0,0 @@
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
@@ -1,22 +0,0 @@
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
@@ -1,18 +0,0 @@
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
+13 -4
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
# These extensions should be enabled to support this database # These extensions should be enabled to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"
@@ -552,9 +552,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.string "api_key_sid" t.string "api_key_sid"
t.jsonb "content_templates", default: {} t.jsonb "content_templates", default: {}
t.datetime "content_templates_last_updated" 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 ["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 ["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 t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
@@ -571,6 +568,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
end 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| create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
t.string "website_url" t.string "website_url"
t.integer "account_id" t.integer "account_id"
@@ -2,13 +2,13 @@ module Enterprise::ContactInboxBuilder
private private
def generate_source_id def generate_source_id
return super unless twilio_voice_inbox? return super unless @inbox.channel_type == 'Channel::Voice'
phone_source_id phone_source_id
end end
def phone_source_id def phone_source_id
return super unless twilio_voice_inbox? return super unless @inbox.channel_type == 'Channel::Voice'
return SecureRandom.uuid if @contact.phone_number.blank? return SecureRandom.uuid if @contact.phone_number.blank?
@@ -16,10 +16,6 @@ module Enterprise::ContactInboxBuilder
end end
def allowed_channels? def allowed_channels?
super || twilio_voice_inbox? super || @inbox.channel_type == 'Channel::Voice'
end
def twilio_voice_inbox?
@inbox.channel_type == 'Channel::TwilioSms' && @inbox.channel.voice_enabled?
end end
end end
@@ -2,13 +2,8 @@ module Enterprise::Messages::MessageBuilder
private private
def message_type def message_type
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call' return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
super super
end end
def twilio_voice_inbox?
inbox = @conversation.inbox
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
end
end end
@@ -7,10 +7,7 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze
def create def create
result = process_bulk_action @responses = process_bulk_action
return if performed?
@responses = result
end end
private private
@@ -18,13 +15,13 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
def validate_params def validate_params
return if params[:type].present? && params[:ids].present? && params[:fields].present? return if params[:type].present? && params[:ids].present? && params[:fields].present?
render json: { success: false }, status: :unprocessable_content render json: { success: false }, status: :unprocessable_entity
end end
def type_matches? def type_matches?
return if MODEL_TYPE.include?(params[:type]) return if MODEL_TYPE.include?(params[:type])
render json: { success: false }, status: :unprocessable_content render json: { success: false }, status: :unprocessable_entity
end end
def process_bulk_action def process_bulk_action
@@ -51,38 +48,13 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
end end
def handle_documents def handle_documents
case params[:fields][:status] return [] unless params[:fields][:status] == 'delete'
when 'delete'
delete_documents
when 'sync'
sync_documents
else
[]
end
end
def delete_documents
documents = Current.account.captain_documents.where(id: params[:ids]) documents = Current.account.captain_documents.where(id: params[:ids])
return render json: { count: 0 } unless documents.exists? return [] unless documents.exists?
destroyed_documents = documents.destroy_all 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 end
def permitted_params 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_current_page, only: [:index]
before_action :set_documents, except: [:create] before_action :set_documents, except: [:create]
before_action :set_document, only: [:show, :destroy, :sync] before_action :set_document, only: [:show, :destroy]
before_action :set_assistant, only: [:create] before_action :set_assistant, only: [:create]
RESULTS_PER_PAGE = 25 RESULTS_PER_PAGE = 25
@@ -29,16 +29,6 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
render_could_not_create_error(e.record.errors.full_messages.join(', ')) render_could_not_create_error(e.record.errors.full_messages.join(', '))
end 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 def destroy
@document.destroy @document.destroy
head :no_content head :no_content
@@ -30,14 +30,9 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
end end
def voice_inbox def voice_inbox
@voice_inbox ||= begin @voice_inbox ||= Current.user.assigned_inboxes.where(
inbox = Current.user.assigned_inboxes.where( account_id: Current.account.id,
account_id: Current.account.id, channel_type: 'Channel::Voice'
channel_type: 'Channel::TwilioSms' ).find(params.require(:inbox_id))
).find(params.require(:inbox_id))
raise ActiveRecord::RecordNotFound, 'Voice not enabled' unless inbox.channel.voice_enabled?
inbox
end
end end
end end
@@ -14,46 +14,20 @@ module Enterprise::Api::V1::Accounts::InboxesController
end end
def channel_type_from_params def channel_type_from_params
return Channel::TwilioSms if permitted_params[:channel][:type] == 'voice' case permitted_params[:channel][:type]
when 'voice'
super Channel::Voice
else
super
end
end end
def account_channels_method def account_channels_method
return Current.account.twilio_sms if permitted_params[:channel][:type] == 'voice' case permitted_params[:channel][:type]
when 'voice'
super Current.account.voice_channels
end else
super
def create_channel end
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
end end
@@ -2,11 +2,11 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper include BillingHelper
before_action :fetch_account before_action :fetch_account
before_action :check_authorization before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion] before_action :check_cloud_env, only: [:checkout, :limits, :toggle_deletion]
def subscription def subscription
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank? if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
@account.update(custom_attributes: { is_creating_customer: true }) @account.update(custom_attributes: subscription_custom_attributes)
Enterprise::CreateStripeCustomerJob.perform_later(@account) Enterprise::CreateStripeCustomerJob.perform_later(@account)
end end
head :no_content head :no_content
@@ -98,6 +98,19 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
@account.custom_attributes['stripe_customer_id'] @account.custom_attributes['stripe_customer_id']
end end
def subscription_custom_attributes
attributes = @account.custom_attributes.merge('is_creating_customer' => true)
attributes['billing_attribution'] = billing_attribution if billing_attribution.present?
attributes
end
def billing_attribution
{
'datafast_visitor_id' => cookies[:datafast_visitor_id],
'datafast_session_id' => cookies[:datafast_session_id]
}.compact
end
def mark_for_deletion def mark_for_deletion
reason = 'manual_deletion' reason = 'manual_deletion'
@@ -169,10 +169,8 @@ class Twilio::VoiceController < ApplicationController
def set_inbox! def set_inbox!
digits = params[:phone].to_s.gsub(/\D/, '') digits = params[:phone].to_s.gsub(/\D/, '')
phone_number = "+#{digits}" e164 = "+#{digits}"
channel = Channel::TwilioSms.find_by!(phone_number: phone_number) channel = Channel::Voice.find_by!(phone_number: e164)
raise ActiveRecord::RecordNotFound, "Voice not enabled for #{phone_number}" unless channel.voice_enabled?
@inbox = channel.inbox @inbox = channel.inbox
end end
@@ -1,9 +1,6 @@
class Captain::Documents::PerformSyncJob < MutexApplicationJob class Captain::Documents::PerformSyncJob < MutexApplicationJob
queue_as :low 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 LOCK_TIMEOUT = 10.minutes
# Safety net for anything we didn't rescue by name — parser bugs, ActiveRecord blips, # Safety net for anything we didn't rescue by name — parser bugs, ActiveRecord blips,
@@ -81,7 +78,6 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
def handle_unexpected_failure(document, error, start_time) def handle_unexpected_failure(document, error, start_time)
document.update!( document.update!(
sync_status: :failed, sync_status: :failed,
sync_step: nil,
last_sync_error_code: 'sync_error', last_sync_error_code: 'sync_error',
last_sync_attempted_at: Time.current last_sync_attempted_at: Time.current
) )
@@ -1,65 +0,0 @@
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,11 +14,7 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
external_link: canonical_url, external_link: canonical_url,
content: payload[:markdown], content: payload[:markdown],
name: metadata['title'], 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 rescue StandardError => e
raise "Failed to parse FireCrawl data: #{e.message}" raise "Failed to parse FireCrawl data: #{e.message}"
@@ -1,10 +1,6 @@
class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
class PermanentCrawlError < StandardError; end
queue_as :low queue_as :low
discard_on PermanentCrawlError
def perform(assistant_id:, page_link:) def perform(assistant_id:, page_link:)
assistant = Captain::Assistant.find(assistant_id) assistant = Captain::Assistant.find(assistant_id)
account = assistant.account account = assistant.account
@@ -15,71 +11,23 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
end end
crawler = Captain::Tools::SimplePageCrawlService.new(page_link) crawler = Captain::Tools::SimplePageCrawlService.new(page_link)
page_title = crawler.page_title || ''
content = crawler.body_text_content || ''
normalized_link = normalize_link(page_link) normalized_link = normalize_link(page_link)
document = assistant.documents.find_or_initialize_by(external_link: normalized_link) document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
handle_failed_fetch!(document, crawler.status_code, page_link) unless crawler.success? document.update!(
external_link: normalized_link,
persist_document!(document, normalized_link, crawler) name: page_title[0..254], content: content[0..14_999], status: :available
rescue PermanentCrawlError )
raise
rescue StandardError => e rescue StandardError => e
raise "Failed to parse data: #{page_link} #{e.message}" raise "Failed to parse data: #{page_link} #{e.message}"
end end
private 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) def normalize_link(raw_link)
raw_link.to_s.delete_suffix('/') raw_link.to_s.delete_suffix('/')
end end
+29 -16
View File
@@ -26,14 +26,12 @@
# #
class Captain::Document < ApplicationRecord class Captain::Document < ApplicationRecord
class LimitExceededError < StandardError; end class LimitExceededError < StandardError; end
SYNC_STALE_TIMEOUT = 2.hours
self.table_name = 'captain_documents' self.table_name = 'captain_documents'
belongs_to :assistant, class_name: 'Captain::Assistant' belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
belongs_to :account belongs_to :account
has_one_attached :pdf_file 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, presence: true, unless: -> { pdf_file.attached? }
validates :external_link, uniqueness: { scope: :assistant_id }, allow_blank: true validates :external_link, uniqueness: { scope: :assistant_id }, allow_blank: true
@@ -61,7 +59,6 @@ class Captain::Document < ApplicationRecord
scope :for_account, ->(account_id) { where(account_id: account_id) } scope :for_account, ->(account_id) { where(account_id: account_id) }
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_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? def pdf_document?
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf' return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
@@ -78,8 +75,36 @@ class Captain::Document < ApplicationRecord
pdf_file.blob.byte_size if pdf_file.attached? pdf_file.blob.byte_size if pdf_file.attached?
end 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) def store_openai_file_id(file_id)
update!(openai_file_id: file_id) update!(metadata: (metadata || {}).merge('openai_file_id' => file_id))
end end
def display_url def display_url
@@ -96,18 +121,6 @@ class Captain::Document < ApplicationRecord
{ document_id: id, assistant_id: assistant_id, external_link: external_link } { document_id: id, assistant_id: assistant_id, external_link: external_link }
end 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 private
def enqueue_crawl_job def enqueue_crawl_job
+122
View File
@@ -0,0 +1,122 @@
# == 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,11 +1,4 @@
module Enterprise::Account 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 # 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 # this is a temporary method since current administrate doesn't support virtual attributes
def manually_managed_features; end def manually_managed_features; end
@@ -41,14 +34,6 @@ module Enterprise::Account
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
end 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? def saml_enabled?
saml_settings&.saml_enabled? || false saml_settings&.saml_enabled? || false
end end
@@ -1,81 +0,0 @@
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,6 +16,7 @@ module Enterprise::Concerns::Account
has_many :copilot_threads, dependent: :destroy_async has_many :copilot_threads, dependent: :destroy_async
has_many :companies, 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_many :calls, dependent: :destroy_async
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings' has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
@@ -23,10 +23,6 @@ class Captain::AssistantPolicy < ApplicationPolicy
@account_user.administrator? @account_user.administrator?
end end
def sync?
@account_user.administrator?
end
def playground? def playground?
true true
end end
@@ -20,7 +20,7 @@ class Captain::Documents::SinglePageFetcher
private private
def firecrawl_configured? def firecrawl_configured?
Captain::Tools::FirecrawlService.configured? InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
end end
def fetch_with_firecrawl def fetch_with_firecrawl
@@ -52,13 +52,14 @@ class Captain::Documents::SinglePageFetcher
end end
def fetch_with_fallback def fetch_with_fallback
crawler = Captain::Tools::SimplePageCrawlService.new(@url) response = HTTParty.get(@url)
return Result.new(success: false, error_code: http_error_code(crawler.status_code)) unless crawler.success? return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
parser = Captain::Tools::HtmlPageParser.new(response.body)
Result.new( Result.new(
success: true, success: true,
title: crawler.page_title&.truncate(TITLE_MAX_LENGTH, omission: ''), title: parser.title&.truncate(TITLE_MAX_LENGTH, omission: ''),
content: crawler.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '') content: parser.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
) )
end end
@@ -12,7 +12,7 @@ class Captain::Documents::SyncService
end end
def perform def perform
@document.update!(sync_step: 'fetching') @document.store_sync_step('fetching')
result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch
unless result.success unless result.success
@@ -20,21 +20,17 @@ class Captain::Documents::SyncService
raise_for_error_code(result.error_code) raise_for_error_code(result.error_code)
end end
@document.update!(sync_step: 'comparing') @document.store_sync_step('comparing')
new_fingerprint = compute_fingerprint(result.content) fingerprint = compute_fingerprint(result.content)
previous_fingerprint = @document.content_fingerprint
if new_fingerprint == previous_fingerprint if fingerprint == @document.content_fingerprint
mark_synced mark_synced
return :unchanged return :unchanged
end end
@document.update!(sync_step: 'updating') @document.store_sync_step('updating')
update_content(result, new_fingerprint) update_content(result, fingerprint)
:updated
# 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 end
private private
@@ -46,7 +42,6 @@ class Captain::Documents::SyncService
def mark_failed(error_code) def mark_failed(error_code)
@document.update!( @document.update!(
sync_status: :failed, sync_status: :failed,
sync_step: nil,
last_sync_error_code: error_code, last_sync_error_code: error_code,
last_sync_attempted_at: Time.current last_sync_attempted_at: Time.current
) )
@@ -55,7 +50,6 @@ class Captain::Documents::SyncService
def mark_synced def mark_synced
@document.update!( @document.update!(
sync_status: :synced, sync_status: :synced,
sync_step: nil,
last_synced_at: Time.current, last_synced_at: Time.current,
last_sync_attempted_at: Time.current, last_sync_attempted_at: Time.current,
last_sync_error_code: nil last_sync_error_code: nil
@@ -68,7 +62,6 @@ class Captain::Documents::SyncService
name: result.title.presence || @document.name, name: result.title.presence || @document.name,
content_fingerprint: fingerprint, content_fingerprint: fingerprint,
sync_status: :synced, sync_status: :synced,
sync_step: nil,
last_synced_at: Time.current, last_synced_at: Time.current,
last_sync_attempted_at: Time.current, last_sync_attempted_at: Time.current,
last_sync_error_code: nil last_sync_error_code: nil
@@ -1,6 +1,5 @@
class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
include Integrations::LlmInstrumentation include Integrations::LlmInstrumentation
MAX_CONTENT_LENGTH = 8000 MAX_CONTENT_LENGTH = 8000
def initialize(website_url) def initialize(website_url)
@@ -31,7 +30,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
def fetch_website_content def fetch_website_content
crawler = Captain::Tools::SimplePageCrawlService.new(@website_url) crawler = Captain::Tools::SimplePageCrawlService.new(@website_url)
text_content = crawler.body_markdown text_content = crawler.body_text_content
page_title = crawler.page_title page_title = crawler.page_title
meta_description = crawler.meta_description meta_description = crawler.meta_description
@@ -2,14 +2,9 @@ class Captain::Tools::FirecrawlService
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].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 def initialize
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value @api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
raise 'Missing API key' if @api_key.blank? raise 'Missing API key' if @api_key.empty?
end end
def perform(url, webhook_url, crawl_limit = 10) def perform(url, webhook_url, crawl_limit = 10)
@@ -1,16 +1,12 @@
class Captain::Tools::SimplePageCrawlService class Captain::Tools::SimplePageCrawlService
attr_reader :external_link, :status_code attr_reader :external_link
def initialize(external_link) def initialize(external_link)
@external_link = external_link @external_link = external_link
@parser = Captain::Tools::HtmlPageParser.new(fetch_body) @parser = Captain::Tools::HtmlPageParser.new(HTTParty.get(external_link).body)
@doc = @parser.doc @doc = @parser.doc
end end
def success?
status_code.to_i.between?(200, 299)
end
def page_links def page_links
sitemap? ? extract_links_from_sitemap : extract_links_from_html sitemap? ? extract_links_from_sitemap : extract_links_from_html
end end
@@ -19,7 +15,7 @@ class Captain::Tools::SimplePageCrawlService
@parser.title @parser.title
end end
def body_markdown def body_text_content
@parser.body_markdown @parser.body_markdown
end end
@@ -39,21 +35,6 @@ class Captain::Tools::SimplePageCrawlService
private 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? def sitemap?
@external_link.end_with?('.xml') @external_link.end_with?('.xml')
end end
@@ -14,6 +14,8 @@ class Enterprise::Billing::HandleStripeEventService
process_subscription_updated process_subscription_updated
when 'customer.subscription.deleted' when 'customer.subscription.deleted'
process_subscription_deleted process_subscription_deleted
when 'invoice.paid', 'invoice.payment_succeeded'
process_invoice_paid
else else
Rails.logger.debug { "Unhandled event type: #{event.type}" } Rails.logger.debug { "Unhandled event type: #{event.type}" }
end end
@@ -74,6 +76,12 @@ class Enterprise::Billing::HandleStripeEventService
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end end
def process_invoice_paid
return if invoice_account.blank?
Enterprise::Billing::TrackPaymentAttributionService.new(account: invoice_account, invoice: invoice).perform
end
def handle_subscription_credits(plan, previous_usage) def handle_subscription_credits(plan, previous_usage)
current_limits = account.limits || {} current_limits = account.limits || {}
@@ -109,6 +117,10 @@ class Enterprise::Billing::HandleStripeEventService
@subscription ||= @event.data.object @subscription ||= @event.data.object
end end
def invoice
@invoice ||= @event.data.object
end
def previous_attributes def previous_attributes
@previous_attributes ||= JSON.parse((@event.data.previous_attributes || {}).to_json) @previous_attributes ||= JSON.parse((@event.data.previous_attributes || {}).to_json)
end end
@@ -132,6 +144,10 @@ class Enterprise::Billing::HandleStripeEventService
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first @account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
end end
def invoice_account
@invoice_account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", invoice.customer).first
end
def find_plan(plan_id) def find_plan(plan_id)
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || [] cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
cloud_plans.find { |config| config['product_id'].include?(plan_id) } cloud_plans.find { |config| config['product_id'].include?(plan_id) }
@@ -0,0 +1,100 @@
class Enterprise::Billing::TrackPaymentAttributionService
pattr_initialize [:account!, :invoice!]
API_ENDPOINT = 'https://datafa.st/api/v1/payments'.freeze
API_KEY_CONFIG = 'DATAFAST_API_KEY'.freeze
ZERO_DECIMAL_CURRENCIES = %w[BIF CLP DJF GNF JPY KMF KRW MGA PYG RWF UGX VND VUV XAF XOF XPF].freeze
def perform
return unless trackable?
response = HTTParty.post(
API_ENDPOINT,
headers: {
'Authorization' => "Bearer #{api_key}",
'Content-Type' => 'application/json'
},
body: payload.to_json,
timeout: 5
)
log_failure("#{response.code} #{response.body}") unless response.success?
rescue StandardError => e
log_failure("#{e.class} - #{e.message}")
end
private
def trackable?
ChatwootApp.chatwoot_cloud? && [api_key, datafast_visitor_id, amount_paid, currency, transaction_id].all?(&:present?)
end
def payload
{
amount: amount,
currency: currency.upcase,
transaction_id: transaction_id,
datafast_visitor_id: datafast_visitor_id,
email: customer_email,
name: customer_name,
customer_id: customer_id,
renewal: renewal?
}.compact
end
def amount
return amount_paid if ZERO_DECIMAL_CURRENCIES.include?(currency.upcase)
amount_paid.to_f / 100
end
def amount_paid
invoice_value('amount_paid')
end
def currency
invoice_value('currency')
end
def transaction_id
invoice_value('id')
end
def customer_id
invoice_value('customer') || account.custom_attributes['stripe_customer_id']
end
def customer_email
invoice_value('customer_email') || account.administrators.first&.email
end
def customer_name
invoice_value('customer_name') || account.name
end
def renewal?
invoice_value('billing_reason') == 'subscription_cycle'
end
def datafast_visitor_id
attribution['datafast_visitor_id']
end
def attribution
account.custom_attributes['billing_attribution'] || {}
end
def api_key
GlobalConfigService.load(API_KEY_CONFIG, nil)
end
def log_failure(message)
Rails.logger.warn("Payment attribution failed for invoice #{transaction_id}: #{message}")
end
def invoice_value(key)
invoice[key]
rescue NoMethodError
nil
end
end
@@ -1,9 +1,9 @@
module Enterprise::Contacts::ContactableInboxesService module Enterprise::Contacts::ContactableInboxesService
private private
# Extend base selection to include voice-enabled TwilioSms inboxes # Extend base selection to include Voice inboxes
def get_contactable_inbox(inbox) def get_contactable_inbox(inbox)
return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled? return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Voice'
super super
end end
@@ -1,33 +0,0 @@
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,7 +17,8 @@ class Twilio::VoiceWebhookSetupService
private private
def validate_token_credentials! def validate_token_credentials!
channel.client.incoming_phone_numbers.list(limit: 1) # Only validate Account SID + Auth Token
token_client.incoming_phone_numbers.list(limit: 1)
rescue StandardError => e rescue StandardError => e
log_twilio_error('AUTH_VALIDATION_TOKEN', e) log_twilio_error('AUTH_VALIDATION_TOKEN', e)
raise raise
@@ -25,7 +26,7 @@ class Twilio::VoiceWebhookSetupService
def create_twiml_app! def create_twiml_app!
friendly_name = "Chatwoot Voice #{channel.phone_number}" friendly_name = "Chatwoot Voice #{channel.phone_number}"
app = channel.client.applications.create( app = api_key_client.applications.create(
friendly_name: friendly_name, friendly_name: friendly_name,
voice_url: channel.voice_call_webhook_url, voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD voice_method: HTTP_METHOD
@@ -37,25 +38,39 @@ class Twilio::VoiceWebhookSetupService
end end
def configure_number_webhooks! def configure_number_webhooks!
numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number) numbers = api_key_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
if numbers.empty? if numbers.empty?
Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}" Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
return return
end end
channel.client api_key_client
.incoming_phone_numbers(numbers.first.sid) .incoming_phone_numbers(numbers.first.sid)
.update( .update(
voice_url: channel.voice_call_webhook_url, voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD, voice_method: HTTP_METHOD,
status_callback: channel.voice_status_webhook_url, status_callback: channel.voice_status_webhook_url,
status_callback_method: HTTP_METHOD status_callback_method: HTTP_METHOD
) )
rescue StandardError => e rescue StandardError => e
log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e) log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e)
raise raise
end 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) def log_twilio_error(context, error)
details = build_error_details(context, error) details = build_error_details(context, error)
add_twilio_specific_details(details, error) add_twilio_specific_details(details, error)
@@ -65,10 +80,11 @@ class Twilio::VoiceWebhookSetupService
end end
def build_error_details(context, error) def build_error_details(context, error)
cfg = channel.provider_config.with_indifferent_access
{ {
context: context, context: context,
phone_number: channel.phone_number, phone_number: channel.phone_number,
account_sid: channel.account_sid, account_sid: cfg[:account_sid],
error_class: error.class.to_s, error_class: error.class.to_s,
message: error.message message: error.message
} }
@@ -43,6 +43,10 @@ class Voice::Provider::Twilio::Adapter
end end
def twilio_client def twilio_client
Twilio::REST::Client.new(@channel.account_sid, @channel.auth_token) Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
def config
@config ||= @channel.provider_config_hash
end end
end end
@@ -1,5 +1,5 @@
class Voice::Provider::Twilio::ConferenceService class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!] pattr_initialize [:conversation!, { twilio_client: nil }]
def ensure_conference_sid def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid') existing = conversation.additional_attributes&.dig('conference_sid')
@@ -19,11 +19,10 @@ class Voice::Provider::Twilio::ConferenceService
end end
def end_conference def end_conference
client = conversation.inbox.channel.client twilio_client
client
.conferences .conferences
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress') .list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
.each { |conf| client.conferences(conf.sid).update(status: 'completed') } .each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
end end
private private
@@ -32,4 +31,16 @@ class Voice::Provider::Twilio::ConferenceService
current = conversation.additional_attributes || {} current = conversation.additional_attributes || {}
conversation.update!(additional_attributes: current.merge(attrs)) conversation.update!(additional_attributes: current.merge(attrs))
end 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 end
@@ -6,20 +6,20 @@ class Voice::Provider::Twilio::TokenService
token: access_token.to_jwt, token: access_token.to_jwt,
identity: identity, identity: identity,
voice_enabled: true, voice_enabled: true,
account_sid: channel.account_sid, account_sid: config['account_sid'],
agent_id: user.id, agent_id: user.id,
account_id: account.id, account_id: account.id,
inbox_id: inbox.id, inbox_id: inbox.id,
phone_number: channel.phone_number, phone_number: inbox.channel.phone_number,
twiml_endpoint: twiml_url, twiml_endpoint: twiml_url,
has_twiml_app: channel.twiml_app_sid.present? has_twiml_app: config['twiml_app_sid'].present?
} }
end end
private private
def channel def config
@channel ||= inbox.channel @config ||= inbox.channel.provider_config_hash || {}
end end
def identity def identity
@@ -28,9 +28,9 @@ class Voice::Provider::Twilio::TokenService
def access_token def access_token
Twilio::JWT::AccessToken.new( Twilio::JWT::AccessToken.new(
channel.account_sid, config['account_sid'],
channel.api_key_sid, config['api_key_sid'],
channel.api_key_secret, config['api_key_secret'],
identity: identity, identity: identity,
ttl: 1.hour.to_i ttl: 1.hour.to_i
).tap { |token| token.add_grant(voice_grant) } ).tap { |token| token.add_grant(voice_grant) }
@@ -39,7 +39,7 @@ class Voice::Provider::Twilio::TokenService
def voice_grant def voice_grant
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant| Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
grant.incoming_allow = true grant.incoming_allow = true
grant.outgoing_application_sid = channel.twiml_app_sid grant.outgoing_application_sid = config['twiml_app_sid']
grant.outgoing_application_params = outgoing_params grant.outgoing_application_params = outgoing_params
end end
end end
@@ -50,13 +50,13 @@ class Voice::Provider::Twilio::TokenService
agent_id: user.id, agent_id: user.id,
identity: identity, identity: identity,
client_name: identity, client_name: identity,
accountSid: channel.account_sid, accountSid: config['account_sid'],
is_agent: 'true' is_agent: 'true'
} }
end end
def twiml_url def twiml_url
digits = channel.phone_number.delete_prefix('+') digits = inbox.channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits) Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end end
end end
-10
View File
@@ -1,10 +0,0 @@
# 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
@@ -19,8 +19,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
:captain_document, :captain_document,
2, 2,
assistant: assistant, assistant: assistant,
account: account, account: account
status: :available
) )
end end
@@ -116,7 +115,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
} }
end end
it 'deletes the documents and returns the deleted count' do it 'deletes the documents and returns an empty array' do
expect do expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions", post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: document_delete_params, params: document_delete_params,
@@ -125,118 +124,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
end.to change(Captain::Document, :count).by(-2) end.to change(Captain::Document, :count).by(-2)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json_response).to eq({ count: documents.size }) expect(json_response).to eq([])
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
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(:agent) { create(:user, account: account, role: :agent) }
let(:assistant) { create(:captain_assistant, account: account) } let(:assistant) { create(:captain_assistant, account: account) }
let(:assistant2) { create(:captain_assistant, account: account) } let(:assistant2) { create(:captain_assistant, account: account) }
let(:document) { create(:captain_document, assistant: assistant, account: account, status: :available) } let(:document) { create(:captain_document, assistant: assistant, account: account) }
let(:captain_limits) do let(:captain_limits) do
{ {
:startups => { :documents => 1, :responses => 100 } :startups => { :documents => 1, :responses => 100 }
@@ -141,27 +141,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect(json_response[:name]).to eq(document.name) expect(json_response[:name]).to eq(document.name)
expect(json_response[:external_link]).to eq(document.external_link) expect(json_response[:external_link]).to eq(document.external_link)
end 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
end end
@@ -256,98 +235,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
end end
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 describe 'DELETE /api/v1/accounts/:account_id/captain/documents/:id' do
context 'when it is an un-authenticated user' do context 'when it is an un-authenticated user' do
before do before do
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:voice_channel) { create(:channel_voice, account: account) }
let(:voice_inbox) { voice_channel.inbox } let(:voice_inbox) { voice_channel.inbox }
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) } let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
let(:admin) { create(:user, :administrator, account: account) } let(:admin) { create(:user, :administrator, account: account) }
@@ -24,11 +24,6 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
end end
it 'creates a voice inbox when administrator' do 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, allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService,
perform: "AP#{SecureRandom.hex(16)}")) perform: "AP#{SecureRandom.hex(16)}"))
@@ -39,7 +34,8 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}", provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
auth_token: SecureRandom.hex(16), auth_token: SecureRandom.hex(16),
api_key_sid: SecureRandom.hex(8), api_key_sid: SecureRandom.hex(8),
api_key_secret: SecureRandom.hex(16) } } }, api_key_secret: SecureRandom.hex(16),
twiml_app_sid: "AP#{SecureRandom.hex(16)}" } } },
as: :json as: :json
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
@@ -5,6 +5,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
let!(:admin) { create(:user, account: account, role: :administrator) } let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:agent) { create(:user, account: account, role: :agent) } let!(:agent) { create(:user, account: account, role: :agent) }
before do
allow(GlobalConfig).to receive(:get_value).and_call_original
end
describe 'POST /enterprise/api/v1/accounts/{account.id}/subscription' do describe 'POST /enterprise/api/v1/accounts/{account.id}/subscription' do
context 'when it is an unauthenticated user' do context 'when it is an unauthenticated user' do
it 'returns unauthorized' do it 'returns unauthorized' do
@@ -35,6 +39,21 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
expect(account.reload.custom_attributes).to eq({ 'is_creating_customer': true }.with_indifferent_access) expect(account.reload.custom_attributes).to eq({ 'is_creating_customer': true }.with_indifferent_access)
end end
it 'stores billing attribution from request cookies' do
post "/enterprise/api/v1/accounts/#{account.id}/subscription",
headers: admin.create_new_auth_token.merge(
'Cookie' => 'datafast_visitor_id=visitor-123; datafast_session_id=session-123'
),
as: :json
expect(account.reload.custom_attributes['billing_attribution']).to eq(
{
'datafast_visitor_id' => 'visitor-123',
'datafast_session_id' => 'session-123'
}
)
end
it 'does not enqueue a job if a job is already enqueued' do it 'does not enqueue a job if a job is already enqueued' do
account.update!(custom_attributes: { is_creating_customer: true }) account.update!(custom_attributes: { is_creating_customer: true })
@@ -79,6 +98,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
end end
context 'when it is an admin and the stripe customer id is not present' do context 'when it is an admin and the stripe customer id is not present' do
before do
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
end
it 'returns error' do it 'returns error' do
post "/enterprise/api/v1/accounts/#{account.id}/checkout", post "/enterprise/api/v1/accounts/#{account.id}/checkout",
headers: admin.create_new_auth_token, headers: admin.create_new_auth_token,
@@ -91,6 +114,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
end end
context 'when it is an admin and the stripe customer is present' do context 'when it is an admin and the stripe customer is present' do
before do
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
end
it 'calls create session' do it 'calls create session' do
account.update!(custom_attributes: { 'stripe_customer_id': 'cus_random_string' }) account.update!(custom_attributes: { 'stripe_customer_id': 'cus_random_string' })
@@ -122,8 +149,8 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
before do before do
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud') allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }]) InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(value: [{ 'name' => 'Hacker' }])
end end
context 'when it is an agent' do context 'when it is an agent' do
@@ -158,8 +185,8 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
before do before do
create(:conversation, account: account) create(:conversation, account: account)
create(:channel_api, account: account) create(:channel_api, account: account)
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud') allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }]) InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(value: [{ 'name' => 'Hacker' }])
end end
it 'returns the limits if the plan is default' do it 'returns the limits if the plan is default' do
@@ -252,10 +279,12 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
let(:stripe_invoice) { Struct.new(:id).new('inv_test123') } let(:stripe_invoice) { Struct.new(:id).new('inv_test123') }
before do before do
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [ InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] }, value: [
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] } { 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
]) { 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
]
)
end end
it 'returns unauthorized for unauthenticated user' do it 'returns unauthorized for unauthenticated user' do
@@ -341,7 +370,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
context 'when deployment environment is not cloud' do context 'when deployment environment is not cloud' do
before do before do
# Set deployment environment to something other than cloud # Set deployment environment to something other than cloud
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'self_hosted') allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('self_hosted')
end end
it 'returns not found' do it 'returns not found' do
@@ -358,7 +387,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
context 'when it is an admin' do context 'when it is an admin' do
before do before do
# Create the installation config for cloud environment # Create the installation config for cloud environment
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
end end
it 'marks the account for deletion when action is delete' do it 'marks the account for deletion when action is delete' do
@@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe 'Twilio::VoiceController', type: :request do RSpec.describe 'Twilio::VoiceController', type: :request do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230003') } let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230003') }
let(:inbox) { channel.inbox } let(:inbox) { channel.inbox }
let(:digits) { channel.phone_number.delete_prefix('+') } let(:digits) { channel.phone_number.delete_prefix('+') }
@@ -1,217 +0,0 @@
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,22 +15,17 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
end end
it 'creates a new document when one does not exist' do it 'creates a new document when one does not exist' do
freeze_time do expect do
expect do described_class.perform_now(assistant_id: assistant.id, payload: payload)
described_class.perform_now(assistant_id: assistant.id, payload: payload) end.to change(assistant.documents, :count).by(1)
end.to change(assistant.documents, :count).by(1)
document = assistant.documents.last document = assistant.documents.last
expect(document).to have_attributes( expect(document).to have_attributes(
content: payload[:markdown], content: payload[:markdown],
name: payload[:metadata]['title'], name: payload[:metadata]['title'],
external_link: 'https://www.firecrawl.dev', external_link: 'https://www.firecrawl.dev',
status: 'available', status: 'available'
sync_status: 'synced', )
last_synced_at: Time.current,
last_sync_attempted_at: Time.current
)
end
end end
it 'updates existing document when one exists' do it 'updates existing document when one exists' do
@@ -42,23 +37,18 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
name: 'old title', name: 'old title',
status: :in_progress) status: :in_progress)
freeze_time do expect do
expect do described_class.perform_now(assistant_id: assistant.id, payload: payload)
described_class.perform_now(assistant_id: assistant.id, payload: payload) end.not_to change(assistant.documents, :count)
end.not_to change(assistant.documents, :count)
existing_document.reload existing_document.reload
# Payload URL ends with '/', but we persist the canonical URL without it. # Payload URL ends with '/', but we persist the canonical URL without it.
expect(existing_document).to have_attributes( expect(existing_document).to have_attributes(
external_link: 'https://www.firecrawl.dev', external_link: 'https://www.firecrawl.dev',
content: payload[:markdown], content: payload[:markdown],
name: payload[:metadata]['title'], name: payload[:metadata]['title'],
status: 'available', status: 'available'
sync_status: 'synced', )
last_synced_at: Time.current,
last_sync_attempted_at: Time.current
)
end
end end
context 'when an error occurs' do context 'when an error occurs' do
@@ -14,28 +14,20 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
.and_return(crawler) .and_return(crawler)
allow(crawler).to receive(:page_title).and_return(page_title) allow(crawler).to receive(:page_title).and_return(page_title)
allow(crawler).to receive(:body_markdown).and_return(content) allow(crawler).to receive(:body_text_content).and_return(content)
allow(crawler).to receive(:success?).and_return(true)
end end
context 'when the page is successfully crawled' do context 'when the page is successfully crawled' do
it 'creates a new document if one does not exist' do it 'creates a new document if one does not exist' do
freeze_time do expect do
expect do described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
described_class.perform_now(assistant_id: assistant.id, page_link: page_link) end.to change(assistant.documents, :count).by(1)
end.to change(assistant.documents, :count).by(1)
document = assistant.documents.last document = assistant.documents.last
expect(document.external_link).to eq('https://example.com/page') expect(document.external_link).to eq('https://example.com/page')
expect(document.name).to eq(page_title) expect(document.name).to eq(page_title)
expect(document.content).to eq(content) expect(document.content).to eq(content)
expect(document).to have_attributes( expect(document.status).to eq('available')
status: 'available',
sync_status: 'synced',
last_synced_at: Time.current,
last_sync_attempted_at: Time.current
)
end
end end
it 'updates existing document if one exists' do it 'updates existing document if one exists' do
@@ -45,21 +37,14 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
name: 'Old Title', name: 'Old Title',
content: 'Old content') content: 'Old content')
freeze_time do expect do
expect do described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
described_class.perform_now(assistant_id: assistant.id, page_link: page_link) end.not_to change(assistant.documents, :count)
end.not_to change(assistant.documents, :count)
existing_document.reload existing_document.reload
expect(existing_document.name).to eq(page_title) expect(existing_document.name).to eq(page_title)
expect(existing_document.content).to eq(content) expect(existing_document.content).to eq(content)
expect(existing_document).to have_attributes( expect(existing_document.status).to eq('available')
status: 'available',
sync_status: 'synced',
last_synced_at: Time.current,
last_sync_attempted_at: Time.current
)
end
end end
context 'when title or content exceed maximum length' do context 'when title or content exceed maximum length' do
@@ -68,7 +53,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
before do before do
allow(crawler).to receive(:page_title).and_return(long_title) allow(crawler).to receive(:page_title).and_return(long_title)
allow(crawler).to receive(:body_markdown).and_return(long_content) allow(crawler).to receive(:body_text_content).and_return(long_content)
end end
it 'truncates the title and content' do it 'truncates the title and content' do
@@ -93,82 +78,10 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
end end
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 context 'when title and content are nil' do
before do before do
allow(crawler).to receive(:page_title).and_return(nil) allow(crawler).to receive(:page_title).and_return(nil)
allow(crawler).to receive(:body_markdown).and_return(nil) allow(crawler).to receive(:body_text_content).and_return(nil)
end end
it 'creates document with empty strings and updates the status to available' do it 'creates document with empty strings and updates the status to available' do
-45
View File
@@ -222,51 +222,6 @@ RSpec.describe Account, type: :model do
end end
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 describe 'account deletion' do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) } let(:admin) { create(:user, account: account, role: :administrator) }
@@ -1,103 +0,0 @@
# 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
@@ -0,0 +1,79 @@
# 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
@@ -1,34 +0,0 @@
# 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 describe '#analyze' do
context 'when website content is available and LLM call is successful' do context 'when website content is available and LLM call is successful' do
before do before do
allow(mock_crawler).to receive(:body_markdown).and_return('Welcome to Example Corp') allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home') 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(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico') 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 context 'when website content fetch raises an error' do
before do before do
allow(mock_crawler).to receive(:body_markdown).and_raise(StandardError, 'Network error') allow(mock_crawler).to receive(:body_text_content).and_raise(StandardError, 'Network error')
end end
it 'returns error response' do it 'returns error response' do
@@ -69,7 +69,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
context 'when website content is empty' do context 'when website content is empty' do
before do before do
allow(mock_crawler).to receive(:body_markdown).and_return('') allow(mock_crawler).to receive(:body_text_content).and_return('')
allow(mock_crawler).to receive(:page_title).and_return('') allow(mock_crawler).to receive(:page_title).and_return('')
allow(mock_crawler).to receive(:meta_description).and_return('') allow(mock_crawler).to receive(:meta_description).and_return('')
end end
@@ -84,7 +84,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
context 'when LLM call fails' do context 'when LLM call fails' do
before do before do
allow(mock_crawler).to receive(:body_markdown).and_return('Welcome to Example Corp') allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home') 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(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico') 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') } let(:invalid_response) { instance_double(RubyLLM::Message, content: 'not valid json') }
before do before do
allow(mock_crawler).to receive(:body_markdown).and_return('Welcome to Example Corp') allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home') 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(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico') 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' } let(:website_url) { 'example.com' }
before do before do
allow(mock_crawler).to receive(:body_markdown).and_return('Welcome') allow(mock_crawler).to receive(:body_text_content).and_return('Welcome')
allow(mock_crawler).to receive(:page_title).and_return('Example') 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(:meta_description).and_return('Description')
allow(mock_crawler).to receive(:favicon_url).and_return(nil) allow(mock_crawler).to receive(:favicon_url).and_return(nil)
@@ -33,7 +33,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
end end
it 'raises an error' do it 'raises an error' do
expect { described_class.new }.to raise_error('Missing API key') expect { described_class.new }.to raise_error(NoMethodError)
end end
end end
@@ -36,31 +36,6 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
end end
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 describe '#page_links' do
context 'with HTML page' do context 'with HTML page' do
let(:html_content) do let(:html_content) do
@@ -120,7 +95,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
end end
end end
describe '#body_markdown' do describe '#body_text_content' do
let(:html_content) do let(:html_content) do
<<~HTML <<~HTML
<html> <html>
@@ -142,7 +117,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
end end
it 'converts body content to markdown' do it 'converts body content to markdown' do
expect(service.body_markdown).to eq("# Main Title\n\nConverted markdown") expect(service.body_text_content).to eq("# Main Title\n\nConverted markdown")
expect(ReverseMarkdown).to have_received(:convert).with( expect(ReverseMarkdown).to have_received(:convert).with(
kind_of(Nokogiri::XML::Element), kind_of(Nokogiri::XML::Element),
unknown_tags: :bypass, unknown_tags: :bypass,
@@ -10,25 +10,23 @@ describe Enterprise::Billing::HandleStripeEventService do
before do before do
# Create cloud plans configuration # Create cloud plans configuration
create(:installation_config, { InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(
name: 'CHATWOOT_CLOUD_PLANS', value: [
value: [ { 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] }, { 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] },
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] }, { 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] },
{ 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] }, { 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] } ]
] )
})
create(:installation_config, { InstallationConfig.where(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').first_or_initialize.update!(
name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: {
value: { 'hacker' => { 'responses' => 0 },
'hacker' => { 'responses' => 0 }, 'startups' => { 'responses' => 300 },
'startups' => { 'responses' => 300 }, 'business' => { 'responses' => 500 },
'business' => { 'responses' => 500 }, 'enterprise' => { 'responses' => 800 }
'enterprise' => { 'responses' => 800 } }
} )
})
# Setup common subscription mocks # Setup common subscription mocks
allow(event).to receive(:data).and_return(data) allow(event).to receive(:data).and_return(data)
allow(data).to receive(:object).and_return(subscription) allow(data).to receive(:object).and_return(subscription)
@@ -133,6 +131,25 @@ describe Enterprise::Billing::HandleStripeEventService do
end end
end end
describe 'invoice payment handling' do
let(:invoice) { instance_double(Stripe::Invoice, customer: 'cus_123') }
it 'tracks payment attribution on paid invoices' do
allow(event).to receive(:type).and_return('invoice.paid')
allow(data).to receive(:object).and_return(invoice)
attribution_service = instance_double(Enterprise::Billing::TrackPaymentAttributionService, perform: true)
allow(Enterprise::Billing::TrackPaymentAttributionService)
.to receive(:new)
.with(account: account, invoice: invoice)
.and_return(attribution_service)
stripe_event_service.new.perform(event: event)
expect(attribution_service).to have_received(:perform)
end
end
describe 'plan-specific feature management' do describe 'plan-specific feature management' do
context 'with default plan (Hacker)' do context 'with default plan (Hacker)' do
it 'disables all premium features' do it 'disables all premium features' do
@@ -0,0 +1,73 @@
require 'rails_helper'
RSpec.describe Enterprise::Billing::TrackPaymentAttributionService do
subject(:service) { described_class.new(account: account, invoice: invoice) }
let(:account) do
create(
:account,
custom_attributes: {
'stripe_customer_id' => 'cus_123',
'billing_attribution' => {
'datafast_visitor_id' => 'visitor-123'
}
}
)
end
let!(:admin) { create(:user, account: account, role: :administrator, email: 'admin@example.com') }
let(:invoice) do
{
'id' => 'in_123',
'amount_paid' => 2900,
'currency' => 'usd',
'customer' => 'cus_123',
'customer_name' => 'Acme Finance',
'billing_reason' => 'subscription_create'
}
end
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
allow(HTTParty).to receive(:post).and_return(instance_double(HTTParty::Response, success?: true))
allow(GlobalConfigService).to receive(:load).with('DATAFAST_API_KEY', nil).and_return('test-key')
end
it 'sends payment attribution to the payment API' do
service.perform
expect(HTTParty).to have_received(:post).with(
'https://datafa.st/api/v1/payments',
headers: {
'Authorization' => 'Bearer test-key',
'Content-Type' => 'application/json'
},
body: {
amount: 29.0,
currency: 'USD',
transaction_id: 'in_123',
datafast_visitor_id: 'visitor-123',
email: admin.email,
name: 'Acme Finance',
customer_id: 'cus_123',
renewal: false
}.to_json,
timeout: 5
)
end
it 'skips the API call when attribution is unavailable' do
account.update!(custom_attributes: { 'stripe_customer_id' => 'cus_123' })
service.perform
expect(HTTParty).not_to have_received(:post)
end
it 'skips the API call when the installation config is unavailable' do
allow(GlobalConfigService).to receive(:load).with('DATAFAST_API_KEY', nil).and_return(nil)
service.perform
expect(HTTParty).not_to have_received(:post)
end
end
@@ -9,16 +9,14 @@ RSpec.describe Twilio::VoiceWebhookSetupService do
let(:api_key_secret) { 'api_key_secret_123' } let(:api_key_secret) { 'api_key_secret_123' }
let(:phone_number) { '+15551230001' } let(:phone_number) { '+15551230001' }
let(:frontend_url) { 'https://app.chatwoot.test' } let(:frontend_url) { 'https://app.chatwoot.test' }
let(:account) { create(:account) }
let(:channel) do let(:channel) do
build(:channel_twilio_sms, :with_voice, build(:channel_voice, phone_number: phone_number, provider_config: {
account: account, account_sid: account_sid,
phone_number: phone_number, auth_token: auth_token,
account_sid: account_sid, api_key_sid: api_key_sid,
auth_token: auth_token, api_key_secret: api_key_secret
api_key_sid: api_key_sid, })
api_key_secret: api_key_secret)
end end
let(:twilio_base_url) { "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}" } 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 RSpec.describe Voice::InboundCallBuilder do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') } let(:channel) { create(:channel_voice, account: account, phone_number: '+15551239999') }
let(:inbox) { channel.inbox } let(:inbox) { channel.inbox }
let(:from_number) { '+15550001111' } let(:from_number) { '+15550001111' }
let(:to_number) { channel.phone_number } let(:to_number) { channel.phone_number }
@@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Voice::OutboundCallBuilder do RSpec.describe Voice::OutboundCallBuilder do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230000') } let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230000') }
let(:inbox) { channel.inbox } let(:inbox) { channel.inbox }
let(:user) { create(:user, account: account) } let(:user) { create(:user, account: account) }
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') } let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
@@ -2,7 +2,7 @@ require 'rails_helper'
describe Voice::Provider::Twilio::Adapter do describe Voice::Provider::Twilio::Adapter do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:channel) { create(:channel_voice, account: account) }
let(:adapter) { described_class.new(channel) } let(:adapter) { described_class.new(channel) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) } let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) } 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(calls_double).to receive(:create).and_return(call_instance)
allow(Twilio::REST::Client).to receive(:new) allow(Twilio::REST::Client).to receive(:new)
.with(channel.account_sid, channel.auth_token) .with(channel.provider_config_hash['account_sid'], channel.provider_config_hash['auth_token'])
.and_return(client_double) .and_return(client_double)
result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42) result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42)
@@ -2,15 +2,14 @@ require 'rails_helper'
describe Voice::Provider::Twilio::ConferenceService do describe Voice::Provider::Twilio::ConferenceService do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:channel) { create(:channel_voice, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) } let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
let(:twilio_client) { instance_double(Twilio::REST::Client) } let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:service) { described_class.new(conversation: conversation) } let(:service) { described_class.new(conversation: conversation, twilio_client: twilio_client) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) } let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
before do before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service) allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
end end
describe '#ensure_conference_sid' do describe '#ensure_conference_sid' do
@@ -3,7 +3,7 @@ require 'rails_helper'
describe Voice::Provider::Twilio::TokenService do describe Voice::Provider::Twilio::TokenService do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:user) { create(:user, :administrator, account: account) } let(:user) { create(:user, :administrator, account: account) }
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:voice_channel) { create(:channel_voice, account: account) }
let(:inbox) { voice_channel.inbox } let(:inbox) { voice_channel.inbox }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) } 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' } } content_attributes: { data: { call_sid: call_sid, status: 'ringing' } }
) )
end end
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') } let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230002') }
let(:inbox) { channel.inbox } let(:inbox) { channel.inbox }
let(:from_number) { '+15550002222' } let(:from_number) { '+15550002222' }
let(:call_sid) { 'CATESTSTATUS123' } let(:call_sid) { 'CATESTSTATUS123' }
+21
View File
@@ -0,0 +1,21 @@
# 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
-8
View File
@@ -17,13 +17,5 @@ FactoryBot.define do
trait :whatsapp do trait :whatsapp do
medium { :whatsapp } medium { :whatsapp }
end 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
end end
@@ -1,347 +0,0 @@
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

Some files were not shown because too many files have changed in this diff Show More