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 { useRoute, useRouter } from 'vue-router';
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 { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
@@ -34,7 +34,9 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const voiceInboxes = computed(() =>
(inboxesList.value || []).filter(isVoiceCallEnabled)
(inboxesList.value || []).filter(
inbox => inbox.channel_type === INBOX_TYPES.VOICE
)
);
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 { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
import ContentTemplateSelector from './ContentTemplateSelector.vue';
@@ -28,7 +30,6 @@ const props = defineProps({
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null },
voiceEnabled: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -81,9 +82,11 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
);
});
@@ -108,7 +111,7 @@ watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && !props.voiceEnabled) setSignature();
if (newValue && !isVoiceInbox.value) setSignature();
});
},
{ immediate: true }
@@ -2,7 +2,7 @@
import { ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
@@ -100,8 +100,6 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
@@ -444,7 +442,6 @@ useKeyboardEvents({
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:voice-enabled="voiceCallEnabled"
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
@@ -1,5 +1,4 @@
import { computed } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
export function useChannelIcon(inbox) {
const channelTypeIconMap = {
@@ -15,6 +14,7 @@ export function useChannelIcon(inbox) {
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-woot-voice',
};
const providerIconMap = {
@@ -38,11 +38,6 @@ export function useChannelIcon(inbox) {
icon = 'i-woot-whatsapp';
}
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
if (isVoiceCallEnabled(inboxDetails)) {
icon = 'i-woot-voice';
}
return icon ?? 'i-ri-global-fill';
});
@@ -19,11 +19,8 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-whatsapp');
});
it('returns correct icon for voice-enabled Twilio channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
};
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice');
});
@@ -51,7 +51,6 @@ import {
import {
appendSignature,
collapseSelection,
findNodeToInsertImage,
getContentNode,
insertAtCursor,
@@ -67,7 +66,6 @@ import {
import {
hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter,
isEscape,
} from 'shared/helpers/KeyboardHelpers';
import { createTypingIndicator } from '@chatwoot/utils';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
@@ -517,9 +515,7 @@ function setMenubarPosition({ selection } = {}) {
function checkSelection(editorState) {
showSelectionMenu.value = false;
const { selection } = editorState;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
const hasSelection = !selection.empty && !selection.node;
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
@@ -715,17 +711,12 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
}
function onKeydown(event) {
if (isEscape(event)) {
collapseSelection(editorView);
return true;
}
if (isEnterToSendEnabled()) {
handleLineBreakWhenEnterToSendEnabled(event);
}
if (isCmdPlusEnterToSendEnabled()) {
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
}
return false;
}
function createEditorView() {
@@ -753,9 +744,6 @@ function createEditorView() {
blur: () => {
if (props.disabled) return;
typingIndicator.stop();
// PM keeps its selection on blur — clear the menu flags manually.
isTextSelected.value = false;
editorRoot.value?.classList.remove('has-selection');
emit('blur');
},
paste: (view, event) => {
@@ -17,8 +17,6 @@ import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import { collapseSelection } from 'dashboard/helper/editorHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
@@ -364,33 +362,19 @@ export default {
onKeyup() {
this.$emit('keyup');
},
onKeydown(view, event) {
onKeydown() {
this.$emit('keydown');
if (isEscape(event)) {
if (this.showSlashMenu) {
this.showSlashMenu = false;
this.slashSearchTerm = '';
this.slashMenuPosition = null;
return true;
}
collapseSelection(editorView);
return true;
}
return false;
},
onBlur() {
// ProseMirror keeps its selection on blur — clear the menu flag manually.
this.isTextSelected = false;
this.$refs.editor?.classList.remove('has-selection');
this.$emit('blur');
},
onFocus() {
this.$emit('focus');
},
checkSelection(editorState) {
const { selection } = editorState;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
const hasSelection = !selection.empty && !selection.node;
const { from, to } = editorState.selection;
// Check if there's a selection (from and to are different)
const hasSelection = from !== to;
// If the selection state is the same as the previous state, do nothing
if (hasSelection === this.isTextSelected) return;
// Update the selection state
@@ -47,11 +47,7 @@ const mockStore = createStore({
11: { id: 11, channel_type: INBOX_TYPES.API },
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: {
id: 14,
channel_type: INBOX_TYPES.TWILIO,
voice_enabled: true,
},
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
};
return inboxes[id] || null;
@@ -215,11 +211,11 @@ describe('useInbox', () => {
});
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
// Test Voice (Twilio with voice_enabled)
// Test Voice
wrapper = mount(createTestComponent(14), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.voiceCallEnabled).toBe(true);
expect(wrapper.vm.isAVoiceChannel).toBe(true);
// Test Tiktok
wrapper = mount(createTestComponent(15), {
@@ -278,8 +274,7 @@ describe('useInbox', () => {
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'voiceCallEnabled',
'voiceCallProvider',
'isAVoiceChannel',
];
expectedProperties.forEach(prop => {
@@ -1,11 +1,7 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import {
INBOX_TYPES,
isVoiceCallEnabled,
getVoiceCallProvider,
} from 'dashboard/helper/inbox';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
@@ -138,9 +134,9 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
return {
inbox,
@@ -160,7 +156,6 @@ export const useInbox = (inboxId = null) => {
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
voiceCallEnabled,
voiceCallProvider,
isAVoiceChannel,
};
};
@@ -109,6 +109,11 @@ export const FORMATTING = {
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Tiktok': {
marks: [],
nodes: [],
-1
View File
@@ -36,7 +36,6 @@ export const FEATURE_FLAGS = {
CHATWOOT_V4: 'chatwoot_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok',
CHANNEL_VOICE: 'channel_voice',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
CAPTAIN_V2: 'captain_integration_v2',
@@ -2,7 +2,6 @@ import {
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
Selection,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
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
* Has an optional param 'content' to check if the
+10 -21
View File
@@ -11,29 +11,9 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
VOICE: 'Channel::Voice',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
};
export const getVoiceCallProvider = inbox => {
if (!inbox) return null;
// Callers pass either snake_case (raw API) or camelCase (after camelcaseKeys) shapes.
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
return null;
};
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
@@ -50,6 +30,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -64,6 +45,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.VOICE]: 'i-woot-voice',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
};
@@ -76,6 +58,7 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
@@ -114,6 +97,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default:
return 'chat';
}
@@ -156,6 +142,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.TIKTOK:
return 'brand-tiktok';
case INBOX_TYPES.VOICE:
return 'phone';
default:
return 'chat';
}
@@ -16,7 +16,6 @@ import {
calculateMenuPosition,
stripUnsupportedFormatting,
stripInlineBase64Images,
collapseSelection,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
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', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
@@ -636,17 +636,7 @@
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"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."
}
"CSAT": "CSAT"
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
@@ -145,7 +145,6 @@ const openDelete = inbox => {
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
:voice-enabled="inbox.voice_enabled"
class="text-body-main text-n-slate-11"
/>
</div>
@@ -21,7 +21,6 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -47,7 +46,6 @@ export default {
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
VoiceConfigurationPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
@@ -171,17 +169,19 @@ export default {
},
];
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
if (!this.isAVoiceChannel) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
}
if (this.isAWebWidgetInbox) {
visibleToAllChannelTabs = [
@@ -197,6 +197,7 @@ export default {
this.isATwilioChannel ||
this.isALineChannel ||
this.isAPIInbox ||
this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
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;
},
currentInboxId() {
@@ -829,6 +812,7 @@ export default {
</SettingsFieldSection>
<SettingsFieldSection
v-if="!isAVoiceChannel"
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
>
@@ -1256,12 +1240,6 @@ export default {
>
<ConfigurationPage :inbox="inbox" />
</div>
<div
v-if="selectedTabKey === 'voice-configuration'"
class="mx-6 max-w-4xl"
>
<VoiceConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
@@ -12,10 +12,6 @@ const props = defineProps({
type: String,
default: '',
},
voiceEnabled: {
type: Boolean,
default: false,
},
});
const getters = useStoreGetters();
const { t } = useI18n();
@@ -34,6 +30,7 @@ const i18nMap = {
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::Voice': 'VOICE',
};
const twilioChannelName = () => {
@@ -48,9 +45,6 @@ const readableChannelName = computed(() => {
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
}
if (props.channelType === 'Channel::TwilioSms') {
if (props.voiceEnabled) {
return t('INBOX_MGMT.CHANNELS.VOICE');
}
return twilioChannelName();
}
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
@@ -208,6 +208,24 @@ export default {
</NextButton>
</SettingsFieldSection>
</div>
<div v-else-if="isAVoiceChannel">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div v-else-if="isALineChannel">
<SettingsFieldSection
@@ -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>
</BaseTable>
<div
class="sticky bottom-0 py-4 px-8 -mx-8 z-20 flex items-center justify-between bg-n-surface-1 border-t border-n-weak"
>
<p class="text-body-main text-n-slate-11 mb-0">
<div class="flex items-center justify-between mt-4">
<p class="text-body-main text-n-slate-11">
{{
$t('TEAMS_SETTINGS.AGENTS.SELECTED_COUNT', {
selected: selectedAgents.length,
@@ -88,7 +88,7 @@ export default {
</script>
<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">
<PageHeader
:header-title="headerTitle"
@@ -103,7 +103,7 @@ export default {
</script>
<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">
<PageHeader
:header-title="headerTitle"
@@ -46,8 +46,8 @@
* 2. Nested properties in additional_attributes (browser_language, referer, etc.)
* 3. Nested properties in custom_attributes (conversation_type, etc.)
*/
import { coerceToDate } from '@chatwoot/utils';
import jsonLogic from 'json-logic-js';
import { coerceToDate } from '@chatwoot/utils';
/**
* 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
*
* 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
* (mirrors the backend SQL `tag_id IN (...)` OR semantics)
* 1. When both values are arrays: checks if all items in filterValue exist in conversationValue
* 2. When filterValue is an array but conversationValue is not: checks if conversationValue is included in filterValue
* 3. Otherwise: performs strict equality comparison
*/
@@ -132,9 +131,8 @@ const equalTo = (filterValue, conversationValue) => {
if (filterValue === 'all') return true;
if (Array.isArray(conversationValue)) {
// For array values like labels, match if any filter value is present.
// Mirrors the backend SQL `tag_id IN (...)` (OR semantics).
return filterValue.some(val => conversationValue.includes(val));
// For array values like labels, check if any of the filter values exist in the array
return filterValue.every(val => conversationValue.includes(val));
}
if (!Array.isArray(conversationValue)) {
@@ -416,40 +416,6 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Multi-label equal_to uses OR semantics to mirror the backend SQL `tag_id IN (...)`:
// a conversation matches if ANY of the filter labels is on it.
it('should match conversation with equal_to operator when any of multiple filter labels is present', () => {
const conversation = { labels: ['support'] };
const filters = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: [
{ id: 'support', name: 'Support' },
{ id: 'urgent', name: 'Urgent' },
],
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should not match conversation with equal_to operator when none of multiple filter labels is present', () => {
const conversation = { labels: ['new'] };
const filters = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: [
{ id: 'support', name: 'Support' },
{ id: 'urgent', name: 'Urgent' },
],
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match conversation with is_present operator for labels', () => {
const conversation = { labels: ['support', 'urgent', 'new'] };
const filters = [
+1 -20
View File
@@ -42,19 +42,6 @@ const updateAuthCookie = (cookieContent, baseDomain = '') =>
baseDomain,
});
const getTargetOrigin = () => {
const { baseUrl } = window.$chatwoot || {};
if (!baseUrl) {
return window.location.origin;
}
try {
const url = new URL(baseUrl);
return url.origin;
} catch {
return window.location.origin;
}
};
const updateCampaignReadStatus = baseDomain => {
const expireBy = addHours(new Date(), 1);
setCookieWithDomain('cw_snooze_campaigns_till', Number(expireBy), {
@@ -106,19 +93,13 @@ export const IFrameHelper = {
getBubbleHolder: () => document.getElementsByClassName('woot--bubble-holder'),
sendMessage: (key, value) => {
const element = IFrameHelper.getAppFrame();
const targetOrigin = getTargetOrigin();
if (!targetOrigin) return;
element.contentWindow.postMessage(
`chatwoot-widget:${JSON.stringify({ event: key, ...value })}`,
targetOrigin
'*'
);
},
initPostMessageCommunication: () => {
window.onmessage = e => {
const expectedOrigin = getTargetOrigin();
if (!expectedOrigin || e.origin !== expectedOrigin) {
return;
}
if (
typeof e.data !== 'string' ||
e.data.indexOf('chatwoot-widget:') !== 0
+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 = {
REPLY_TO: 'replyTo',
@@ -59,8 +59,8 @@ export default {
isALineChannel() {
return this.channelType === INBOX_TYPES.LINE;
},
voiceCallEnabled() {
return isVoiceCallEnabled(this.inbox);
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
},
isAnEmailChannel() {
return this.channelType === INBOX_TYPES.EMAIL;
+2 -7
View File
@@ -4,7 +4,6 @@
#
# id :bigint not null, primary key
# account_sid :string not null
# api_key_secret :string
# api_key_sid :string
# auth_token :string not null
# content_templates :jsonb
@@ -12,8 +11,6 @@
# medium :integer default("sms")
# messaging_service_sid :string
# phone_number :string
# twiml_app_sid :string
# voice_enabled :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
@@ -61,6 +58,8 @@ class Channel::TwilioSms < ApplicationRecord
client.messages.create(**params)
end
private
def client
if api_key_sid.present?
Twilio::REST::Client.new(api_key_sid, auth_token, account_sid)
@@ -69,8 +68,6 @@ class Channel::TwilioSms < ApplicationRecord
end
end
private
def send_message_from
if messaging_service_sid?
{ messaging_service_sid: messaging_service_sid }
@@ -79,5 +76,3 @@ class Channel::TwilioSms < ApplicationRecord
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
end
processed_template_params = process_liquid_template_params(contact)
return if processed_template_params.nil?
send_whatsapp_template_message(to: contact.phone_number, template_params: processed_template_params)
send_whatsapp_template_message(to: contact.phone_number)
end
def process_audience(audience_labels)
@@ -73,22 +70,10 @@ class Whatsapp::OneoffCampaignService
Rails.logger.info "Campaign #{campaign.id} processing completed"
end
def process_liquid_template_params(contact)
liquid_processor = Whatsapp::LiquidTemplateProcessorService.new(campaign: campaign, contact: contact)
processed_template_params = liquid_processor.process_template_params(campaign.template_params)
Rails.logger.info "Skipping contact #{contact.name} - liquid variables resolved to blank values" if processed_template_params.nil?
processed_template_params
rescue StandardError => e
Rails.logger.error "Failed to process liquid template params for contact #{contact.name}: #{e.message}"
nil
end
def send_whatsapp_template_message(to:, template_params:)
def send_whatsapp_template_message(to:)
processor = Whatsapp::TemplateProcessorService.new(
channel: channel,
template_params: template_params
template_params: campaign.template_params
)
name, namespace, lang_code, processed_parameters = processor.call
+4 -10
View File
@@ -72,7 +72,6 @@ if resource.twilio?
if Current.account_user&.administrator?
json.auth_token resource.channel.try(:auth_token)
json.account_sid resource.channel.try(:account_sid)
json.api_key_sid resource.channel.try(:api_key_sid)
end
end
@@ -132,13 +131,8 @@ if resource.whatsapp?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
end
## Voice attributes for TwilioSms
if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
json.voice_enabled resource.channel.voice_enabled?
json.voice_configured resource.channel.try(:twiml_app_sid).present?
json.has_api_key_secret resource.channel.try(:api_key_secret).present?
if resource.channel.try(:twiml_app_sid).present?
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
end
## Voice Channel Attributes
if resource.channel_type == 'Channel::Voice'
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
end
+3 -3
View File
@@ -191,10 +191,10 @@
- name: assignment_v2
display_name: Assignment V2
enabled: true
- name: captain_document_auto_sync
display_name: Captain Document Auto Sync
- name: twilio_content_templates
display_name: Twilio Content Templates
enabled: false
premium: true
deprecated: true
- name: advanced_search
display_name: Advanced Search
enabled: false
-5
View File
@@ -40,11 +40,6 @@ Rails.application.reloader.to_prepare do
if File.exist?(schedule_file) && Sidekiq.server?
schedule = YAML.load_file(schedule_file)
# Merge enterprise-only cron entries when running an enterprise build.
# Mirrors the conditional-load pattern already used for enterprise initializers.
enterprise_schedule_file = Rails.root.join('enterprise/config/schedule.yml')
schedule.merge!(YAML.load_file(enterprise_schedule_file)) if ChatwootApp.enterprise? && enterprise_schedule_file.exist?
# Cron entries removed from schedule.yml but possibly still in Redis
# with source:'dynamic' (predating the source tag). load_from_hash!
# only cleans up source:'schedule' entries, so these need explicit removal.
-3
View File
@@ -379,9 +379,6 @@ en:
limit_exceeded: 'Document limit exceeded'
pdf_format_error: 'must be a PDF file'
pdf_size_error: 'must be less than 10MB'
sync_not_supported_for_pdf: 'Sync is not supported for PDF documents'
sync_only_available_documents: 'Sync is only available for processed documents'
sync_already_in_progress: 'Document sync is already in progress'
pdf_upload_failed: 'Failed to upload PDF to OpenAI'
pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
+1 -3
View File
@@ -77,9 +77,7 @@ Rails.application.routes.draw do
resources :custom_tools do
post :test, on: :collection
end
resources :documents, only: [:index, :show, :create, :destroy] do
post :sync, on: :member
end
resources :documents, only: [:index, :show, :create, :destroy]
resource :tasks, only: [], controller: 'tasks' do
post :rewrite
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.
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
enable_extension "pg_stat_statements"
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.jsonb "content_templates", default: {}
t.datetime "content_templates_last_updated"
t.boolean "voice_enabled", default: false, null: false
t.string "twiml_app_sid"
t.string "api_key_secret"
t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true
t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
@@ -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
end
create_table "channel_voice", force: :cascade do |t|
t.string "phone_number", null: false
t.string "provider", default: "twilio", null: false
t.jsonb "provider_config", null: false
t.integer "account_id", null: false
t.jsonb "additional_attributes", default: {}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_channel_voice_on_account_id"
t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
end
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
t.string "website_url"
t.integer "account_id"
@@ -2,13 +2,13 @@ module Enterprise::ContactInboxBuilder
private
def generate_source_id
return super unless twilio_voice_inbox?
return super unless @inbox.channel_type == 'Channel::Voice'
phone_source_id
end
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?
@@ -16,10 +16,6 @@ module Enterprise::ContactInboxBuilder
end
def allowed_channels?
super || twilio_voice_inbox?
end
def twilio_voice_inbox?
@inbox.channel_type == 'Channel::TwilioSms' && @inbox.channel.voice_enabled?
super || @inbox.channel_type == 'Channel::Voice'
end
end
@@ -2,13 +2,8 @@ module Enterprise::Messages::MessageBuilder
private
def message_type
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call'
return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
super
end
def twilio_voice_inbox?
inbox = @conversation.inbox
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
end
end
@@ -7,10 +7,7 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze
def create
result = process_bulk_action
return if performed?
@responses = result
@responses = process_bulk_action
end
private
@@ -18,13 +15,13 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
def validate_params
return if params[:type].present? && params[:ids].present? && params[:fields].present?
render json: { success: false }, status: :unprocessable_content
render json: { success: false }, status: :unprocessable_entity
end
def type_matches?
return if MODEL_TYPE.include?(params[:type])
render json: { success: false }, status: :unprocessable_content
render json: { success: false }, status: :unprocessable_entity
end
def process_bulk_action
@@ -51,38 +48,13 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
end
def handle_documents
case params[:fields][:status]
when 'delete'
delete_documents
when 'sync'
sync_documents
else
[]
end
end
return [] unless params[:fields][:status] == 'delete'
def delete_documents
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
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 }
documents.destroy_all
[]
end
def permitted_params
@@ -4,7 +4,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
before_action :set_current_page, only: [:index]
before_action :set_documents, except: [:create]
before_action :set_document, only: [:show, :destroy, :sync]
before_action :set_document, only: [:show, :destroy]
before_action :set_assistant, only: [:create]
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(', '))
end
def sync
return render_could_not_create_error(I18n.t('captain.documents.sync_not_supported_for_pdf')) unless @document.syncable?
return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available?
return render_could_not_create_error(I18n.t('captain.documents.sync_already_in_progress')) if @document.sync_in_progress?
@document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
Captain::Documents::PerformSyncJob.perform_later(@document)
head :accepted
end
def destroy
@document.destroy
head :no_content
@@ -30,14 +30,9 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
end
def voice_inbox
@voice_inbox ||= begin
inbox = Current.user.assigned_inboxes.where(
account_id: Current.account.id,
channel_type: 'Channel::TwilioSms'
).find(params.require(:inbox_id))
raise ActiveRecord::RecordNotFound, 'Voice not enabled' unless inbox.channel.voice_enabled?
inbox
end
@voice_inbox ||= Current.user.assigned_inboxes.where(
account_id: Current.account.id,
channel_type: 'Channel::Voice'
).find(params.require(:inbox_id))
end
end
@@ -14,46 +14,20 @@ module Enterprise::Api::V1::Accounts::InboxesController
end
def channel_type_from_params
return Channel::TwilioSms if permitted_params[:channel][:type] == 'voice'
super
case permitted_params[:channel][:type]
when 'voice'
Channel::Voice
else
super
end
end
def account_channels_method
return Current.account.twilio_sms if permitted_params[:channel][:type] == 'voice'
super
end
def create_channel
return create_voice_channel if permitted_params[:channel][:type] == 'voice'
super
end
def get_channel_attributes(channel_type)
attrs = super
attrs += [:voice_enabled, :api_key_sid, :api_key_secret] if channel_type == 'Channel::TwilioSms' && @inbox&.channel&.medium == 'sms'
attrs
end
def create_voice_channel
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('channel_voice')
voice_params = params.require(:channel).permit(
:phone_number, :provider,
provider_config: [:account_sid, :auth_token, :api_key_sid, :api_key_secret]
)
config = voice_params[:provider_config] || {}
Current.account.twilio_sms.create!(
phone_number: voice_params[:phone_number],
account_sid: config[:account_sid],
auth_token: config[:auth_token],
api_key_sid: config[:api_key_sid],
api_key_secret: config[:api_key_secret],
medium: :sms,
voice_enabled: true
)
case permitted_params[:channel][:type]
when 'voice'
Current.account.voice_channels
else
super
end
end
end
@@ -2,11 +2,11 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
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
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)
end
head :no_content
@@ -98,6 +98,19 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
@account.custom_attributes['stripe_customer_id']
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
reason = 'manual_deletion'
@@ -169,10 +169,8 @@ class Twilio::VoiceController < ApplicationController
def set_inbox!
digits = params[:phone].to_s.gsub(/\D/, '')
phone_number = "+#{digits}"
channel = Channel::TwilioSms.find_by!(phone_number: phone_number)
raise ActiveRecord::RecordNotFound, "Voice not enabled for #{phone_number}" unless channel.voice_enabled?
e164 = "+#{digits}"
channel = Channel::Voice.find_by!(phone_number: e164)
@inbox = channel.inbox
end
@@ -1,9 +1,6 @@
class Captain::Documents::PerformSyncJob < MutexApplicationJob
queue_as :low
# A single page fetch + fingerprint compare should complete in seconds.
# 10 minutes is generous headroom — if still "syncing" after that, the worker likely died mid-run.
# Shared with ScheduleSyncsJob so stale locks are re-enqueued at the same threshold.
LOCK_TIMEOUT = 10.minutes
# Safety net for anything we didn't rescue by name — parser bugs, ActiveRecord blips,
@@ -81,7 +78,6 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
def handle_unexpected_failure(document, error, start_time)
document.update!(
sync_status: :failed,
sync_step: nil,
last_sync_error_code: 'sync_error',
last_sync_attempted_at: Time.current
)
@@ -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,
content: payload[:markdown],
name: metadata['title'],
status: :available,
sync_status: :synced,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
status: :available
)
rescue StandardError => e
raise "Failed to parse FireCrawl data: #{e.message}"
@@ -1,10 +1,6 @@
class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
class PermanentCrawlError < StandardError; end
queue_as :low
discard_on PermanentCrawlError
def perform(assistant_id:, page_link:)
assistant = Captain::Assistant.find(assistant_id)
account = assistant.account
@@ -15,71 +11,23 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
end
crawler = Captain::Tools::SimplePageCrawlService.new(page_link)
page_title = crawler.page_title || ''
content = crawler.body_text_content || ''
normalized_link = normalize_link(page_link)
document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
handle_failed_fetch!(document, crawler.status_code, page_link) unless crawler.success?
persist_document!(document, normalized_link, crawler)
rescue PermanentCrawlError
raise
document.update!(
external_link: normalized_link,
name: page_title[0..254], content: content[0..14_999], status: :available
)
rescue StandardError => e
raise "Failed to parse data: #{page_link} #{e.message}"
end
private
def handle_failed_fetch!(document, status_code, page_link)
error_code = http_error_code(status_code)
mark_failed!(document, error_code) if document.persisted?
error_message = "Failed to fetch page: #{page_link}"
raise PermanentCrawlError, error_message if permanent_failure?(error_code)
raise error_message
end
def persist_document!(document, normalized_link, crawler)
document.update!(
external_link: normalized_link,
name: (crawler.page_title || '')[0..254],
content: (crawler.body_markdown || '')[0..14_999],
status: :available,
**synced_attributes
)
end
def synced_attributes
{
sync_status: :synced,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
}
end
def mark_failed!(document, error_code)
document.update!(
status: :available,
sync_status: :failed,
last_sync_error_code: error_code,
last_sync_attempted_at: Time.current
)
end
def http_error_code(status_code)
case status_code
when 404 then 'not_found'
when 401, 403 then 'access_denied'
when 408, 504 then 'timeout'
else 'fetch_failed'
end
end
def permanent_failure?(error_code)
Captain::Documents::SyncService::PERMANENT_ERROR_CODES.include?(error_code)
end
def normalize_link(raw_link)
raw_link.to_s.delete_suffix('/')
end
+29 -16
View File
@@ -26,14 +26,12 @@
#
class Captain::Document < ApplicationRecord
class LimitExceededError < StandardError; end
SYNC_STALE_TIMEOUT = 2.hours
self.table_name = 'captain_documents'
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
belongs_to :account
has_one_attached :pdf_file
store_accessor :metadata, :content_fingerprint, :last_sync_error_code, :sync_step, :openai_file_id
validates :external_link, presence: true, unless: -> { pdf_file.attached? }
validates :external_link, uniqueness: { scope: :assistant_id }, allow_blank: true
@@ -61,7 +59,6 @@ class Captain::Document < ApplicationRecord
scope :for_account, ->(account_id) { where(account_id: account_id) }
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
scope :syncable, -> { where("external_link NOT LIKE 'PDF:%' AND external_link NOT LIKE '%.pdf'") }
def pdf_document?
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
@@ -78,8 +75,36 @@ class Captain::Document < ApplicationRecord
pdf_file.blob.byte_size if pdf_file.attached?
end
def content_fingerprint
metadata&.dig('content_fingerprint')
end
def content_fingerprint=(value)
self.metadata = (metadata || {}).merge('content_fingerprint' => value)
end
def last_sync_error_code
metadata&.dig('last_sync_error_code')
end
def last_sync_error_code=(value)
self.metadata = (metadata || {}).merge('last_sync_error_code' => value)
end
def sync_step
metadata&.dig('sync_step')
end
def store_sync_step(step)
update!(metadata: (metadata || {}).merge('sync_step' => step))
end
def openai_file_id
metadata&.dig('openai_file_id')
end
def store_openai_file_id(file_id)
update!(openai_file_id: file_id)
update!(metadata: (metadata || {}).merge('openai_file_id' => file_id))
end
def display_url
@@ -96,18 +121,6 @@ class Captain::Document < ApplicationRecord
{ document_id: id, assistant_id: assistant_id, external_link: external_link }
end
def syncable?
!pdf_document?
end
def sync_stale?
sync_syncing? && (last_sync_attempted_at.blank? || last_sync_attempted_at < SYNC_STALE_TIMEOUT.ago)
end
def sync_in_progress?
sync_syncing? && !sync_stale?
end
private
def enqueue_crawl_job
+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
CAPTAIN_SYNC_INTERVALS = {
'hacker' => nil,
'startups' => 7.days,
'business' => 1.day,
'enterprise' => 6.hours
}.freeze
# TODO: Remove this when we upgrade administrate gem to the latest version
# this is a temporary method since current administrate doesn't support virtual attributes
def manually_managed_features; end
@@ -41,14 +34,6 @@ module Enterprise::Account
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
end
def captain_document_sync_interval
plan = custom_attributes['plan_name']
plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
return nil if plan.blank?
CAPTAIN_SYNC_INTERVALS[plan.downcase]
end
def saml_enabled?
saml_settings&.saml_enabled? || false
end
@@ -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 :companies, dependent: :destroy_async
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
has_many :calls, dependent: :destroy_async
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
@@ -23,10 +23,6 @@ class Captain::AssistantPolicy < ApplicationPolicy
@account_user.administrator?
end
def sync?
@account_user.administrator?
end
def playground?
true
end
@@ -20,7 +20,7 @@ class Captain::Documents::SinglePageFetcher
private
def firecrawl_configured?
Captain::Tools::FirecrawlService.configured?
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
end
def fetch_with_firecrawl
@@ -52,13 +52,14 @@ class Captain::Documents::SinglePageFetcher
end
def fetch_with_fallback
crawler = Captain::Tools::SimplePageCrawlService.new(@url)
return Result.new(success: false, error_code: http_error_code(crawler.status_code)) unless crawler.success?
response = HTTParty.get(@url)
return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
parser = Captain::Tools::HtmlPageParser.new(response.body)
Result.new(
success: true,
title: crawler.page_title&.truncate(TITLE_MAX_LENGTH, omission: ''),
content: crawler.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
title: parser.title&.truncate(TITLE_MAX_LENGTH, omission: ''),
content: parser.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
)
end
@@ -12,7 +12,7 @@ class Captain::Documents::SyncService
end
def perform
@document.update!(sync_step: 'fetching')
@document.store_sync_step('fetching')
result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch
unless result.success
@@ -20,21 +20,17 @@ class Captain::Documents::SyncService
raise_for_error_code(result.error_code)
end
@document.update!(sync_step: 'comparing')
new_fingerprint = compute_fingerprint(result.content)
previous_fingerprint = @document.content_fingerprint
@document.store_sync_step('comparing')
fingerprint = compute_fingerprint(result.content)
if new_fingerprint == previous_fingerprint
if fingerprint == @document.content_fingerprint
mark_synced
return :unchanged
end
@document.update!(sync_step: 'updating')
update_content(result, new_fingerprint)
# Without a prior fingerprint we cannot tell a first-ever sync apart from a real
# change, so treat it as unchanged to keep downstream signals quiet on baseline.
previous_fingerprint.present? ? :updated : :unchanged
@document.store_sync_step('updating')
update_content(result, fingerprint)
:updated
end
private
@@ -46,7 +42,6 @@ class Captain::Documents::SyncService
def mark_failed(error_code)
@document.update!(
sync_status: :failed,
sync_step: nil,
last_sync_error_code: error_code,
last_sync_attempted_at: Time.current
)
@@ -55,7 +50,6 @@ class Captain::Documents::SyncService
def mark_synced
@document.update!(
sync_status: :synced,
sync_step: nil,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
@@ -68,7 +62,6 @@ class Captain::Documents::SyncService
name: result.title.presence || @document.name,
content_fingerprint: fingerprint,
sync_status: :synced,
sync_step: nil,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
@@ -1,6 +1,5 @@
class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
include Integrations::LlmInstrumentation
MAX_CONTENT_LENGTH = 8000
def initialize(website_url)
@@ -31,7 +30,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
def fetch_website_content
crawler = Captain::Tools::SimplePageCrawlService.new(@website_url)
text_content = crawler.body_markdown
text_content = crawler.body_text_content
page_title = crawler.page_title
meta_description = crawler.meta_description
@@ -2,14 +2,9 @@ class Captain::Tools::FirecrawlService
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
def self.configured?
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value
.present?
end
def initialize
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
raise 'Missing API key' if @api_key.blank?
raise 'Missing API key' if @api_key.empty?
end
def perform(url, webhook_url, crawl_limit = 10)
@@ -1,16 +1,12 @@
class Captain::Tools::SimplePageCrawlService
attr_reader :external_link, :status_code
attr_reader :external_link
def initialize(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
end
def success?
status_code.to_i.between?(200, 299)
end
def page_links
sitemap? ? extract_links_from_sitemap : extract_links_from_html
end
@@ -19,7 +15,7 @@ class Captain::Tools::SimplePageCrawlService
@parser.title
end
def body_markdown
def body_text_content
@parser.body_markdown
end
@@ -39,21 +35,6 @@ class Captain::Tools::SimplePageCrawlService
private
def fetch_body
body = ''
SafeFetch.fetch(external_link, validate_content_type: false) do |result|
body = result.tempfile.read
end
@status_code = 200
body
rescue SafeFetch::HttpError => e
@status_code = e.message.to_i
''
rescue SafeFetch::Error
@status_code = nil
''
end
def sitemap?
@external_link.end_with?('.xml')
end
@@ -14,6 +14,8 @@ class Enterprise::Billing::HandleStripeEventService
process_subscription_updated
when 'customer.subscription.deleted'
process_subscription_deleted
when 'invoice.paid', 'invoice.payment_succeeded'
process_invoice_paid
else
Rails.logger.debug { "Unhandled event type: #{event.type}" }
end
@@ -74,6 +76,12 @@ class Enterprise::Billing::HandleStripeEventService
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
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)
current_limits = account.limits || {}
@@ -109,6 +117,10 @@ class Enterprise::Billing::HandleStripeEventService
@subscription ||= @event.data.object
end
def invoice
@invoice ||= @event.data.object
end
def previous_attributes
@previous_attributes ||= JSON.parse((@event.data.previous_attributes || {}).to_json)
end
@@ -132,6 +144,10 @@ class Enterprise::Billing::HandleStripeEventService
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
end
def invoice_account
@invoice_account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", invoice.customer).first
end
def find_plan(plan_id)
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
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
private
# Extend base selection to include voice-enabled TwilioSms inboxes
# Extend base selection to include Voice inboxes
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
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
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
log_twilio_error('AUTH_VALIDATION_TOKEN', e)
raise
@@ -25,7 +26,7 @@ class Twilio::VoiceWebhookSetupService
def create_twiml_app!
friendly_name = "Chatwoot Voice #{channel.phone_number}"
app = channel.client.applications.create(
app = api_key_client.applications.create(
friendly_name: friendly_name,
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD
@@ -37,25 +38,39 @@ class Twilio::VoiceWebhookSetupService
end
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?
Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
return
end
channel.client
.incoming_phone_numbers(numbers.first.sid)
.update(
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD,
status_callback: channel.voice_status_webhook_url,
status_callback_method: HTTP_METHOD
)
api_key_client
.incoming_phone_numbers(numbers.first.sid)
.update(
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD,
status_callback: channel.voice_status_webhook_url,
status_callback_method: HTTP_METHOD
)
rescue StandardError => e
log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e)
raise
end
def api_key_client
@api_key_client ||= begin
cfg = channel.provider_config.with_indifferent_access
::Twilio::REST::Client.new(cfg[:api_key_sid], cfg[:api_key_secret], cfg[:account_sid])
end
end
def token_client
@token_client ||= begin
cfg = channel.provider_config.with_indifferent_access
::Twilio::REST::Client.new(cfg[:account_sid], cfg[:auth_token])
end
end
def log_twilio_error(context, error)
details = build_error_details(context, error)
add_twilio_specific_details(details, error)
@@ -65,10 +80,11 @@ class Twilio::VoiceWebhookSetupService
end
def build_error_details(context, error)
cfg = channel.provider_config.with_indifferent_access
{
context: context,
phone_number: channel.phone_number,
account_sid: channel.account_sid,
account_sid: cfg[:account_sid],
error_class: error.class.to_s,
message: error.message
}
@@ -43,6 +43,10 @@ class Voice::Provider::Twilio::Adapter
end
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
@@ -1,5 +1,5 @@
class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!]
pattr_initialize [:conversation!, { twilio_client: nil }]
def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid')
@@ -19,11 +19,10 @@ class Voice::Provider::Twilio::ConferenceService
end
def end_conference
client = conversation.inbox.channel.client
client
twilio_client
.conferences
.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
private
@@ -32,4 +31,16 @@ class Voice::Provider::Twilio::ConferenceService
current = conversation.additional_attributes || {}
conversation.update!(additional_attributes: current.merge(attrs))
end
def twilio_client
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
end
def account_sid
@account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid']
end
def auth_token
@auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token']
end
end
@@ -6,20 +6,20 @@ class Voice::Provider::Twilio::TokenService
token: access_token.to_jwt,
identity: identity,
voice_enabled: true,
account_sid: channel.account_sid,
account_sid: config['account_sid'],
agent_id: user.id,
account_id: account.id,
inbox_id: inbox.id,
phone_number: channel.phone_number,
phone_number: inbox.channel.phone_number,
twiml_endpoint: twiml_url,
has_twiml_app: channel.twiml_app_sid.present?
has_twiml_app: config['twiml_app_sid'].present?
}
end
private
def channel
@channel ||= inbox.channel
def config
@config ||= inbox.channel.provider_config_hash || {}
end
def identity
@@ -28,9 +28,9 @@ class Voice::Provider::Twilio::TokenService
def access_token
Twilio::JWT::AccessToken.new(
channel.account_sid,
channel.api_key_sid,
channel.api_key_secret,
config['account_sid'],
config['api_key_sid'],
config['api_key_secret'],
identity: identity,
ttl: 1.hour.to_i
).tap { |token| token.add_grant(voice_grant) }
@@ -39,7 +39,7 @@ class Voice::Provider::Twilio::TokenService
def voice_grant
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
grant.incoming_allow = true
grant.outgoing_application_sid = channel.twiml_app_sid
grant.outgoing_application_sid = config['twiml_app_sid']
grant.outgoing_application_params = outgoing_params
end
end
@@ -50,13 +50,13 @@ class Voice::Provider::Twilio::TokenService
agent_id: user.id,
identity: identity,
client_name: identity,
accountSid: channel.account_sid,
accountSid: config['account_sid'],
is_agent: 'true'
}
end
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)
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,
2,
assistant: assistant,
account: account,
status: :available
account: account
)
end
@@ -116,7 +115,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
}
end
it 'deletes the documents and returns the deleted count' do
it 'deletes the documents and returns an empty array' do
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
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)
expect(response).to have_http_status(:ok)
expect(json_response).to eq({ count: documents.size })
end
end
context 'when syncing documents' do
let(:sync_params) do
{
type: 'AssistantDocument',
ids: documents.map(&:id),
fields: { status: 'sync' }
}
end
before { clear_enqueued_jobs }
it 'queues a sync for each web document and returns the enqueued document ids' do
freeze_time do
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params,
headers: admin.create_new_auth_token,
as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(documents.size).times
documents.each do |document|
expect(document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end
end
expect(response).to have_http_status(:ok)
expect(json_response).to eq({ ids: documents.map(&:id), count: documents.size })
end
it 'skips PDF documents because they are not syncable' do
pdf_document = build(:captain_document, assistant: assistant, account: account)
pdf_document.pdf_file.attach(io: StringIO.new('PDF content'), filename: 'test.pdf',
content_type: 'application/pdf')
pdf_document.save!
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params.merge(ids: [pdf_document.id]),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:ok)
end
it 'skips documents that are still being processed' do
in_progress_document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params.merge(ids: [in_progress_document.id]),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:ok)
end
it 'skips documents that already have a sync in progress' do
syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available)
syncing_document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params.merge(ids: [syncing_document.id]),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:ok)
end
it 'queues stale syncing documents again' do
syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available)
freeze_time do
syncing_document.update!(
sync_status: :syncing,
last_sync_attempted_at: (Captain::Document::SYNC_STALE_TIMEOUT + 1.minute).ago
)
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params.merge(ids: [syncing_document.id]),
headers: admin.create_new_auth_token,
as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(syncing_document)
expect(syncing_document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end
expect(response).to have_http_status(:ok)
expect(json_response).to eq({ ids: [syncing_document.id], count: 1 })
end
it 'denies non-administrators' do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
expect(json_response).to eq([])
end
end
@@ -6,7 +6,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:assistant2) { create(:captain_assistant, account: account) }
let(:document) { create(:captain_document, assistant: assistant, account: account, status: :available) }
let(:document) { create(:captain_document, assistant: assistant, account: account) }
let(:captain_limits) do
{
: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[:external_link]).to eq(document.external_link)
end
it 'returns sync metadata when the document has been synced' do
synced_at = 1.hour.ago
document.update!(sync_status: :synced, last_synced_at: synced_at)
get "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}",
headers: agent.create_new_auth_token, as: :json
expect(json_response[:sync_status]).to eq('synced')
expect(json_response[:last_synced_at]).to eq(synced_at.to_i)
end
it 'does not report failed documents without a successful sync as last synced' do
document.update!(sync_status: :failed, last_sync_attempted_at: 1.minute.ago)
get "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}",
headers: agent.create_new_auth_token, as: :json
expect(json_response[:sync_status]).to eq('failed')
expect(json_response[:last_synced_at]).to be_nil
end
end
end
@@ -256,98 +235,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
end
end
describe 'POST /api/v1/accounts/:account_id/captain/documents/:id/sync' do
before { clear_enqueued_jobs }
context 'when it is an un-authenticated user' do
it 'returns unauthorized status' do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'denies the request' do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
it 'queues a sync and returns accepted' do
freeze_time do
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
expect(document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end
expect(response).to have_http_status(:accepted)
end
it 'rejects documents that already have a sync in progress' do
document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'queues stale syncing documents again' do
freeze_time do
document.update!(sync_status: :syncing, last_sync_attempted_at: (Captain::Document::SYNC_STALE_TIMEOUT + 1.minute).ago)
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
expect(document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end
expect(response).to have_http_status(:accepted)
end
it 'rejects PDF documents with an explanatory error' do
pdf_document = build(:captain_document, assistant: assistant, account: account)
pdf_document.pdf_file.attach(io: StringIO.new('PDF content'), filename: 'test.pdf',
content_type: 'application/pdf')
pdf_document.save!
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{pdf_document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects documents that are still being processed' do
in_progress_document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{in_progress_document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'DELETE /api/v1/accounts/:account_id/captain/documents/:id' do
context 'when it is an un-authenticated user' do
before do
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
let(:account) { create(:account) }
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:voice_channel) { create(:channel_voice, account: account) }
let(:voice_inbox) { voice_channel.inbox }
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
let(:admin) { create(:user, :administrator, account: account) }
@@ -24,11 +24,6 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
end
it 'creates a voice inbox when administrator' do
account.enable_features('channel_voice')
account.save!
stub_request(:get, %r{api\.twilio\.com/2010-04-01/Accounts/.*/IncomingPhoneNumbers\.json})
.to_return(status: 200, body: { incoming_phone_numbers: [{ capabilities: { 'voice' => true } }] }.to_json,
headers: { 'Content-Type' => 'application/json' })
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService,
perform: "AP#{SecureRandom.hex(16)}"))
@@ -39,7 +34,8 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
auth_token: SecureRandom.hex(16),
api_key_sid: SecureRandom.hex(8),
api_key_secret: SecureRandom.hex(16) } } },
api_key_secret: SecureRandom.hex(16),
twiml_app_sid: "AP#{SecureRandom.hex(16)}" } } },
as: :json
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!(: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
context 'when it is an unauthenticated user' 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)
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
account.update!(custom_attributes: { is_creating_customer: true })
@@ -79,6 +98,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
end
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
post "/enterprise/api/v1/accounts/#{account.id}/checkout",
headers: admin.create_new_auth_token,
@@ -91,6 +114,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
end
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
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
before do
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }])
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(value: [{ 'name' => 'Hacker' }])
end
context 'when it is an agent' do
@@ -158,8 +185,8 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
before do
create(:conversation, account: account)
create(:channel_api, account: account)
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }])
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return('cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(value: [{ 'name' => 'Hacker' }])
end
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') }
before do
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
])
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(
value: [
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
]
)
end
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
before do
# 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
it 'returns not found' do
@@ -358,7 +387,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
context 'when it is an admin' do
before do
# 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
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
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(: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
it 'creates a new document when one does not exist' do
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, payload: payload)
end.to change(assistant.documents, :count).by(1)
expect do
described_class.perform_now(assistant_id: assistant.id, payload: payload)
end.to change(assistant.documents, :count).by(1)
document = assistant.documents.last
expect(document).to have_attributes(
content: payload[:markdown],
name: payload[:metadata]['title'],
external_link: 'https://www.firecrawl.dev',
status: 'available',
sync_status: 'synced',
last_synced_at: Time.current,
last_sync_attempted_at: Time.current
)
end
document = assistant.documents.last
expect(document).to have_attributes(
content: payload[:markdown],
name: payload[:metadata]['title'],
external_link: 'https://www.firecrawl.dev',
status: 'available'
)
end
it 'updates existing document when one exists' do
@@ -42,23 +37,18 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
name: 'old title',
status: :in_progress)
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, payload: payload)
end.not_to change(assistant.documents, :count)
expect do
described_class.perform_now(assistant_id: assistant.id, payload: payload)
end.not_to change(assistant.documents, :count)
existing_document.reload
# Payload URL ends with '/', but we persist the canonical URL without it.
expect(existing_document).to have_attributes(
external_link: 'https://www.firecrawl.dev',
content: payload[:markdown],
name: payload[:metadata]['title'],
status: 'available',
sync_status: 'synced',
last_synced_at: Time.current,
last_sync_attempted_at: Time.current
)
end
existing_document.reload
# Payload URL ends with '/', but we persist the canonical URL without it.
expect(existing_document).to have_attributes(
external_link: 'https://www.firecrawl.dev',
content: payload[:markdown],
name: payload[:metadata]['title'],
status: 'available'
)
end
context 'when an error occurs' do
@@ -14,28 +14,20 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
.and_return(crawler)
allow(crawler).to receive(:page_title).and_return(page_title)
allow(crawler).to receive(:body_markdown).and_return(content)
allow(crawler).to receive(:success?).and_return(true)
allow(crawler).to receive(:body_text_content).and_return(content)
end
context 'when the page is successfully crawled' do
it 'creates a new document if one does not exist' do
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.to change(assistant.documents, :count).by(1)
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.to change(assistant.documents, :count).by(1)
document = assistant.documents.last
expect(document.external_link).to eq('https://example.com/page')
expect(document.name).to eq(page_title)
expect(document.content).to eq(content)
expect(document).to have_attributes(
status: 'available',
sync_status: 'synced',
last_synced_at: Time.current,
last_sync_attempted_at: Time.current
)
end
document = assistant.documents.last
expect(document.external_link).to eq('https://example.com/page')
expect(document.name).to eq(page_title)
expect(document.content).to eq(content)
expect(document.status).to eq('available')
end
it 'updates existing document if one exists' do
@@ -45,21 +37,14 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
name: 'Old Title',
content: 'Old content')
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.not_to change(assistant.documents, :count)
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.not_to change(assistant.documents, :count)
existing_document.reload
expect(existing_document.name).to eq(page_title)
expect(existing_document.content).to eq(content)
expect(existing_document).to have_attributes(
status: 'available',
sync_status: 'synced',
last_synced_at: Time.current,
last_sync_attempted_at: Time.current
)
end
existing_document.reload
expect(existing_document.name).to eq(page_title)
expect(existing_document.content).to eq(content)
expect(existing_document.status).to eq('available')
end
context 'when title or content exceed maximum length' do
@@ -68,7 +53,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
before do
allow(crawler).to receive(:page_title).and_return(long_title)
allow(crawler).to receive(:body_markdown).and_return(long_content)
allow(crawler).to receive(:body_text_content).and_return(long_content)
end
it 'truncates the title and content' do
@@ -93,82 +78,10 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
end
end
context 'when the page fetch fails' do
before do
allow(crawler).to receive(:success?).and_return(false)
allow(crawler).to receive(:status_code).and_return(500)
end
it 'raises an error without creating an available document' do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.to raise_error("Failed to parse data: #{page_link} Failed to fetch page: #{page_link}")
.and not_change(assistant.documents, :count)
end
it 'marks an existing document as available and failed' do
document = create(
:captain_document,
assistant: assistant,
account: assistant.account,
external_link: 'https://example.com/page',
status: :in_progress
)
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.to raise_error("Failed to parse data: #{page_link} Failed to fetch page: #{page_link}")
expect(document.reload).to have_attributes(
status: 'available',
sync_status: 'failed',
last_sync_error_code: 'fetch_failed',
last_sync_attempted_at: Time.current
)
end
end
context 'when the failure is permanent' do
before do
allow(crawler).to receive(:status_code).and_return(404)
end
it 'does not retry a discovered link that was never persisted' do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.not_to change(assistant.documents, :count)
end
it 'marks an existing document as available and failed without raising' do
document = create(
:captain_document,
assistant: assistant,
account: assistant.account,
external_link: 'https://example.com/page',
status: :in_progress
)
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.not_to raise_error
expect(document.reload).to have_attributes(
status: 'available',
sync_status: 'failed',
last_sync_error_code: 'not_found',
last_sync_attempted_at: Time.current
)
end
end
end
end
context 'when title and content are nil' do
before do
allow(crawler).to receive(:page_title).and_return(nil)
allow(crawler).to receive(:body_markdown).and_return(nil)
allow(crawler).to receive(:body_text_content).and_return(nil)
end
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
describe 'captain document sync cadence' do
let(:account) { create(:account) }
it 'has no cadence on the hacker plan' do
account.update!(custom_attributes: { plan_name: 'hacker' })
expect(account.captain_document_sync_interval).to be_nil
end
it 'syncs weekly on the startups plan' do
account.update!(custom_attributes: { plan_name: 'startups' })
expect(account.captain_document_sync_interval).to eq(7.days)
end
it 'syncs daily on the business plan' do
account.update!(custom_attributes: { plan_name: 'business' })
expect(account.captain_document_sync_interval).to eq(1.day)
end
it 'syncs every six hours on the enterprise plan' do
account.update!(custom_attributes: { plan_name: 'enterprise' })
expect(account.captain_document_sync_interval).to eq(6.hours)
end
it 'has no cadence when plan is missing' do
account.update!(custom_attributes: {})
expect(account.captain_document_sync_interval).to be_nil
end
it 'has no cadence for unknown plans' do
account.update!(custom_attributes: { plan_name: 'mystery' })
expect(account.captain_document_sync_interval).to be_nil
end
it 'normalizes plan name casing' do
account.update!(custom_attributes: { plan_name: 'Business' })
expect(account.captain_document_sync_interval).to eq(1.day)
end
it 'syncs every six hours on self-hosted enterprise installs without a plan_name' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
account.update!(custom_attributes: {})
expect(account.captain_document_sync_interval).to eq(6.hours)
end
end
describe 'account deletion' do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
@@ -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
context 'when website content is available and LLM call is successful' 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(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
@@ -56,7 +56,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
context 'when website content fetch raises an error' do
before do
allow(mock_crawler).to receive(:body_markdown).and_raise(StandardError, 'Network error')
allow(mock_crawler).to receive(:body_text_content).and_raise(StandardError, 'Network error')
end
it 'returns error response' do
@@ -69,7 +69,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
context 'when website content is empty' do
before do
allow(mock_crawler).to receive(:body_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(:meta_description).and_return('')
end
@@ -84,7 +84,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
context 'when LLM call fails' do
before do
allow(mock_crawler).to receive(:body_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(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
@@ -103,7 +103,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
let(:invalid_response) { instance_double(RubyLLM::Message, content: 'not valid json') }
before do
allow(mock_crawler).to receive(:body_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(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
@@ -122,7 +122,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
let(:website_url) { 'example.com' }
before do
allow(mock_crawler).to receive(:body_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(:meta_description).and_return('Description')
allow(mock_crawler).to receive(:favicon_url).and_return(nil)
@@ -33,7 +33,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
end
it 'raises an error' do
expect { described_class.new }.to raise_error('Missing API key')
expect { described_class.new }.to raise_error(NoMethodError)
end
end
@@ -36,31 +36,6 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
end
end
describe '#success?' do
context 'when the fetch succeeds' do
before do
stub_request(:get, base_url)
.to_return(status: 200, body: '<html><head><title>Example Page</title></head></html>')
end
it 'returns true' do
expect(service.success?).to be(true)
expect(service.status_code).to eq(200)
end
end
context 'when the fetch returns a non-success response' do
before do
stub_request(:get, base_url).to_return(status: 404, body: 'Not found')
end
it 'returns false and exposes the status code' do
expect(service.success?).to be(false)
expect(service.status_code).to eq(404)
end
end
end
describe '#page_links' do
context 'with HTML page' do
let(:html_content) do
@@ -120,7 +95,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
end
end
describe '#body_markdown' do
describe '#body_text_content' do
let(:html_content) do
<<~HTML
<html>
@@ -142,7 +117,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
end
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(
kind_of(Nokogiri::XML::Element),
unknown_tags: :bypass,
@@ -10,25 +10,23 @@ describe Enterprise::Billing::HandleStripeEventService do
before do
# Create cloud plans configuration
create(:installation_config, {
name: 'CHATWOOT_CLOUD_PLANS',
value: [
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] },
{ 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] },
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
]
})
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(
value: [
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] },
{ 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] },
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
]
)
create(:installation_config, {
name: 'CAPTAIN_CLOUD_PLAN_LIMITS',
value: {
'hacker' => { 'responses' => 0 },
'startups' => { 'responses' => 300 },
'business' => { 'responses' => 500 },
'enterprise' => { 'responses' => 800 }
}
})
InstallationConfig.where(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').first_or_initialize.update!(
value: {
'hacker' => { 'responses' => 0 },
'startups' => { 'responses' => 300 },
'business' => { 'responses' => 500 },
'enterprise' => { 'responses' => 800 }
}
)
# Setup common subscription mocks
allow(event).to receive(:data).and_return(data)
allow(data).to receive(:object).and_return(subscription)
@@ -133,6 +131,25 @@ describe Enterprise::Billing::HandleStripeEventService do
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
context 'with default plan (Hacker)' 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(:phone_number) { '+15551230001' }
let(:frontend_url) { 'https://app.chatwoot.test' }
let(:account) { create(:account) }
let(:channel) do
build(:channel_twilio_sms, :with_voice,
account: account,
phone_number: phone_number,
account_sid: account_sid,
auth_token: auth_token,
api_key_sid: api_key_sid,
api_key_secret: api_key_secret)
build(:channel_voice, phone_number: phone_number, provider_config: {
account_sid: account_sid,
auth_token: auth_token,
api_key_sid: api_key_sid,
api_key_secret: api_key_secret
})
end
let(:twilio_base_url) { "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}" }
@@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Voice::InboundCallBuilder do
let(:account) { create(:account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551239999') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550001111' }
let(:to_number) { channel.phone_number }
@@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Voice::OutboundCallBuilder do
let(:account) { create(:account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230000') }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230000') }
let(:inbox) { channel.inbox }
let(:user) { create(:user, account: account) }
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
@@ -2,7 +2,7 @@ require 'rails_helper'
describe Voice::Provider::Twilio::Adapter do
let(:account) { create(:account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:channel) { create(:channel_voice, account: account) }
let(:adapter) { described_class.new(channel) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) }
@@ -19,7 +19,7 @@ describe Voice::Provider::Twilio::Adapter do
allow(calls_double).to receive(:create).and_return(call_instance)
allow(Twilio::REST::Client).to receive(:new)
.with(channel.account_sid, channel.auth_token)
.with(channel.provider_config_hash['account_sid'], channel.provider_config_hash['auth_token'])
.and_return(client_double)
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
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(: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) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
end
describe '#ensure_conference_sid' do
@@ -3,7 +3,7 @@ require 'rails_helper'
describe Voice::Provider::Twilio::TokenService do
let(:account) { create(:account) }
let(:user) { create(:user, :administrator, account: account) }
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:voice_channel) { create(:channel_voice, account: account) }
let(:inbox) { voice_channel.inbox }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
@@ -27,7 +27,7 @@ RSpec.describe Voice::StatusUpdateService do
content_attributes: { data: { call_sid: call_sid, status: 'ringing' } }
)
end
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230002') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550002222' }
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
medium { :whatsapp }
end
trait :with_voice do
with_phone_number
voice_enabled { true }
api_key_sid { "SK#{SecureRandom.hex(16)}" }
api_key_secret { SecureRandom.hex(16) }
twiml_app_sid { "AP#{SecureRandom.hex(16)}" }
end
end
end
@@ -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