Compare commits

...
Author SHA1 Message Date
Vishnu NarayananandGitHub 8b759496f2 Merge branch 'develop' into fix/missing-postmessage-validation 2026-04-29 17:27:16 +05:30
Aakash BakhleandGitHub 568aae875b feat: wire up auto-sync job backend [AI-150] (#14117)
# Pull Request Template

## Description

- Wires up Controllers to auto-sync job
- adds plan based sync schedule
- a scheduler that runs every hour to check syncable documents
- guards the whole feature behind feature flag by reclaiming
`twilio_content_templates`
- Adds a global and account level cap on how many documents to enqueue
to prevent sudden burst at first run
- some refactor to simplify code
- specs

Fixes # (issue)

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

specs and locally

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-04-29 14:47:14 +05:30
Sivin VargheseandGitHub 2324a344dc fix: keep agents action visible in teams edit/add view (#14304) 2026-04-29 14:42:37 +05:30
Sivin VargheseandGitHub 7c7d67fd06 fix: show all matches when filtering by multiple labels (#14303) 2026-04-29 14:15:37 +05:30
Sivin VargheseandGitHub b058d84034 fix: prevent Escape from opening formatting toolbar in editor (#14133) 2026-04-29 14:15:08 +05:30
0e122188e9 feat: Add voice calling as a capability on Twilio SMS channel(Enterprise) (#13963)
Voice calling is now a capability on the existing TwilioSms rather than
a separate Voice model. A single Twilio phone number handles both SMS
and voice calls through one inbox.
Fixes
https://linear.app/chatwoot/issue/CW-6683/add-voice-calling-as-a-capability-on-twilio-sms-channel
and https://linear.app/chatwoot/issue/PLA-120/add-the-support-for-sms

**What changed**
- Replaced Channel::Voice with voice_enabled flag on Channel::TwilioSms
- Added voice_enabled, twiml_app_sid, api_key_secret columns to
channel_twilio_sms table
  - Dropped channel_voice table (no production data)
- All voice logic lives in Enterprise layer via
prepend_mod_with('Channel::TwilioSms')
- Added Voice settings tab on Twilio SMS inbox settings to
enable/disable voice
  - Validates Twilio number voice capability before provisioning
- Teardown service cleans up TwiML app and credentials when voice is
disabled
- Frontend voice detection uses isVoiceCallEnabled() /
getVoiceCallProvider() helpers — extensible to future providers
  - Gated by channel_voice feature flag

**How to test**

1. Enable feature flag:
Account.find(<id>).enable_features('channel_voice')
2. Create voice inbox: Inboxes → Voice tile → enter Twilio credentials →
verify incoming/outgoing calls and SMS work
3. Enable voice on existing SMS inbox: Inboxes → select Twilio SMS inbox
→ Voice tab → toggle on → provide API key credentials → verify calls
work
4. Disable voice: Voice tab → toggle off → verify TwiML app is deleted,
credentials cleared, SMS still works
5. Re-enable voice: Toggle on again → must provide api_key_secret again
→ new TwiML app provisioned

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-04-29 11:32:19 +04:00
f8f0caf443 feat(campaigns): Add variable support to WhatsApp campaigns (#13649)
Fixes
https://linear.app/chatwoot/issue/CW-5641/add-the-support-for-variables-in-whatsapp-campaign-templates

This PR adds liquid variable support to WhatsApp campaigns, enabling
dynamic per-contact personalization. It supports the same liquid
variables as SMS campaigns ({{contact.name}}, {{contact.email}}, etc.).
Variables are processed per-contact when the campaign executes, allowing
personalized messages at scale.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-04-28 21:57:30 +04:00
Vishnu NarayananandGitHub 98fa5f60e9 Merge branch 'develop' into fix/missing-postmessage-validation 2026-04-24 16:15:00 +05:30
Vishnu NarayananandGitHub 7a8829e79e Merge branch 'develop' into fix/missing-postmessage-validation 2026-04-23 18:22:15 +05:30
Shivam MishraandGitHub bf397750f3 Merge branch 'develop' into fix/missing-postmessage-validation 2026-01-21 13:42:18 +05:30
Vinay KeerthiandGitHub f253ea0abc Merge branch 'develop' into fix/missing-postmessage-validation 2026-01-15 11:38:30 +05:30
Vinay Keerthi 3aea425abc fix: handle same-origin deployments in postMessage validation
Fall back to window.location.origin when baseUrl is empty or relative,
supporting same-origin deployments while maintaining origin validation.
2026-01-12 16:34:13 +05:30
Vinay Keerthi eee307b36b fix: add postMessage origin validation in widget SDK
Adds origin validation for postMessage communication in the widget SDK:
- Extract and validate origin from configured baseUrl
- Use specific target origin instead of wildcard when sending messages
- Validate sender origin when receiving messages

Fixes: CW-6301
2026-01-12 16:27:52 +05:30
95 changed files with 2333 additions and 556 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 { INBOX_TYPES } from 'dashboard/helper/inbox';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
@@ -34,9 +34,7 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const voiceInboxes = computed(() =>
(inboxesList.value || []).filter(
inbox => inbox.channel_type === INBOX_TYPES.VOICE
)
(inboxesList.value || []).filter(isVoiceCallEnabled)
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
@@ -8,8 +8,6 @@ import { useEventListener } from '@vueuse/core';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
import ContentTemplateSelector from './ContentTemplateSelector.vue';
@@ -30,6 +28,7 @@ const props = defineProps({
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null },
voiceEnabled: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -82,11 +81,9 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled
);
});
@@ -111,7 +108,7 @@ watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && !isVoiceInbox.value) setSignature();
if (newValue && !props.voiceEnabled) setSignature();
});
},
{ immediate: true }
@@ -2,7 +2,7 @@
import { ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
@@ -100,6 +100,8 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
@@ -442,6 +444,7 @@ useKeyboardEvents({
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:voice-enabled="voiceCallEnabled"
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
@@ -1,4 +1,5 @@
import { computed } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
export function useChannelIcon(inbox) {
const channelTypeIconMap = {
@@ -14,7 +15,6 @@ export function useChannelIcon(inbox) {
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-woot-voice',
};
const providerIconMap = {
@@ -38,6 +38,11 @@ export function useChannelIcon(inbox) {
icon = 'i-woot-whatsapp';
}
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
if (isVoiceCallEnabled(inboxDetails)) {
icon = 'i-woot-voice';
}
return icon ?? 'i-ri-global-fill';
});
@@ -19,8 +19,11 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-whatsapp');
});
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
it('returns correct icon for voice-enabled Twilio channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice');
});
@@ -51,6 +51,7 @@ import {
import {
appendSignature,
collapseSelection,
findNodeToInsertImage,
getContentNode,
insertAtCursor,
@@ -66,6 +67,7 @@ import {
import {
hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter,
isEscape,
} from 'shared/helpers/KeyboardHelpers';
import { createTypingIndicator } from '@chatwoot/utils';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
@@ -515,7 +517,9 @@ function setMenubarPosition({ selection } = {}) {
function checkSelection(editorState) {
showSelectionMenu.value = false;
const hasSelection = editorState.selection.from !== editorState.selection.to;
const { selection } = editorState;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
const hasSelection = !selection.empty && !selection.node;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
@@ -711,12 +715,17 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
}
function onKeydown(event) {
if (isEscape(event)) {
collapseSelection(editorView);
return true;
}
if (isEnterToSendEnabled()) {
handleLineBreakWhenEnterToSendEnabled(event);
}
if (isCmdPlusEnterToSendEnabled()) {
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
}
return false;
}
function createEditorView() {
@@ -744,6 +753,9 @@ function createEditorView() {
blur: () => {
if (props.disabled) return;
typingIndicator.stop();
// PM keeps its selection on blur — clear the menu flags manually.
isTextSelected.value = false;
editorRoot.value?.classList.remove('has-selection');
emit('blur');
},
paste: (view, event) => {
@@ -17,6 +17,8 @@ import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import { collapseSelection } from 'dashboard/helper/editorHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
@@ -362,19 +364,33 @@ export default {
onKeyup() {
this.$emit('keyup');
},
onKeydown() {
onKeydown(view, event) {
this.$emit('keydown');
if (isEscape(event)) {
if (this.showSlashMenu) {
this.showSlashMenu = false;
this.slashSearchTerm = '';
this.slashMenuPosition = null;
return true;
}
collapseSelection(editorView);
return true;
}
return false;
},
onBlur() {
// ProseMirror keeps its selection on blur — clear the menu flag manually.
this.isTextSelected = false;
this.$refs.editor?.classList.remove('has-selection');
this.$emit('blur');
},
onFocus() {
this.$emit('focus');
},
checkSelection(editorState) {
const { from, to } = editorState.selection;
// Check if there's a selection (from and to are different)
const hasSelection = from !== to;
const { selection } = editorState;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
const hasSelection = !selection.empty && !selection.node;
// If the selection state is the same as the previous state, do nothing
if (hasSelection === this.isTextSelected) return;
// Update the selection state
@@ -47,7 +47,11 @@ const mockStore = createStore({
11: { id: 11, channel_type: INBOX_TYPES.API },
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
14: {
id: 14,
channel_type: INBOX_TYPES.TWILIO,
voice_enabled: true,
},
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
};
return inboxes[id] || null;
@@ -211,11 +215,11 @@ describe('useInbox', () => {
});
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
// Test Voice
// Test Voice (Twilio with voice_enabled)
wrapper = mount(createTestComponent(14), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAVoiceChannel).toBe(true);
expect(wrapper.vm.voiceCallEnabled).toBe(true);
// Test Tiktok
wrapper = mount(createTestComponent(15), {
@@ -274,7 +278,8 @@ describe('useInbox', () => {
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'isAVoiceChannel',
'voiceCallEnabled',
'voiceCallProvider',
];
expectedProperties.forEach(prop => {
@@ -1,7 +1,11 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
INBOX_TYPES,
isVoiceCallEnabled,
getVoiceCallProvider,
} from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
@@ -134,9 +138,9 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
return {
inbox,
@@ -156,6 +160,7 @@ export const useInbox = (inboxId = null) => {
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
isAVoiceChannel,
voiceCallEnabled,
voiceCallProvider,
};
};
@@ -109,11 +109,6 @@ export const FORMATTING = {
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Tiktok': {
marks: [],
nodes: [],
+1
View File
@@ -36,6 +36,7 @@ export const FEATURE_FLAGS = {
CHATWOOT_V4: 'chatwoot_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok',
CHANNEL_VOICE: 'channel_voice',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
CAPTAIN_V2: 'captain_integration_v2',
@@ -2,6 +2,7 @@ import {
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
Selection,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
@@ -273,6 +274,18 @@ export const scrollCursorIntoView = view => {
}
};
/**
* Collapse the current selection to a cursor near its head. Used to override
* the default Escape -> selectParentNode behavior which would otherwise keep
* the text highlight visible.
*
* @param {EditorView} view - The ProseMirror EditorView
*/
export const collapseSelection = view => {
const { tr, selection } = view.state;
view.dispatch(tr.setSelection(Selection.near(selection.$head)));
};
/**
* Returns a transaction that inserts a node into editor at the given position
* Has an optional param 'content' to check if the
+21 -10
View File
@@ -11,9 +11,29 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
VOICE: 'Channel::Voice',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
};
export const getVoiceCallProvider = inbox => {
if (!inbox) return null;
// Callers pass either snake_case (raw API) or camelCase (after camelcaseKeys) shapes.
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
return null;
};
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
@@ -30,7 +50,6 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -45,7 +64,6 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.VOICE]: 'i-woot-voice',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
};
@@ -58,7 +76,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
@@ -97,9 +114,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default:
return 'chat';
}
@@ -142,9 +156,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.TIKTOK:
return 'brand-tiktok';
case INBOX_TYPES.VOICE:
return 'phone';
default:
return 'chat';
}
@@ -16,6 +16,7 @@ import {
calculateMenuPosition,
stripUnsupportedFormatting,
stripInlineBase64Images,
collapseSelection,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
@@ -454,6 +455,37 @@ describe('stripInlineBase64Images', () => {
});
});
describe('collapseSelection', () => {
it('collapses a text range to a cursor at its head', () => {
const editorView = new EditorView(document.body, {
state: createEditorState('Hello world'),
});
// Build a TextSelection via the initial selection's constructor (avoids
// importing prosemirror-state, which isn't a direct dep).
const { doc, selection } = editorView.state;
editorView.dispatch(
editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6))
);
expect(editorView.state.selection.empty).toBe(false);
collapseSelection(editorView);
expect(editorView.state.selection.empty).toBe(true);
expect(editorView.state.selection.head).toBe(6);
});
it('leaves an already-collapsed selection as a cursor', () => {
const editorView = new EditorView(document.body, {
state: createEditorState('Hi'),
});
collapseSelection(editorView);
expect(editorView.state.selection.empty).toBe(true);
});
});
describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
@@ -636,7 +636,17 @@
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
"CSAT": "CSAT",
"VOICE": "Voice"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
"LABEL": "Enable Voice Calling",
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
@@ -145,6 +145,7 @@ const openDelete = inbox => {
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
:voice-enabled="inbox.voice_enabled"
class="text-body-main text-n-slate-11"
/>
</div>
@@ -21,6 +21,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -46,6 +47,7 @@ export default {
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
VoiceConfigurationPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
@@ -169,19 +171,17 @@ export default {
},
];
if (!this.isAVoiceChannel) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
}
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
if (this.isAWebWidgetInbox) {
visibleToAllChannelTabs = [
@@ -197,7 +197,6 @@ export default {
this.isATwilioChannel ||
this.isALineChannel ||
this.isAPIInbox ||
this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox
@@ -232,6 +231,24 @@ export default {
];
}
if (
this.isATwilioChannel &&
this.inbox.phone_number &&
this.inbox.medium === 'sms' &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHANNEL_VOICE
)
) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'voice-configuration',
name: this.$t('INBOX_MGMT.TABS.VOICE'),
},
];
}
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -812,7 +829,6 @@ export default {
</SettingsFieldSection>
<SettingsFieldSection
v-if="!isAVoiceChannel"
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
>
@@ -1240,6 +1256,12 @@ export default {
>
<ConfigurationPage :inbox="inbox" />
</div>
<div
v-if="selectedTabKey === 'voice-configuration'"
class="mx-6 max-w-4xl"
>
<VoiceConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
@@ -12,6 +12,10 @@ const props = defineProps({
type: String,
default: '',
},
voiceEnabled: {
type: Boolean,
default: false,
},
});
const getters = useStoreGetters();
const { t } = useI18n();
@@ -30,7 +34,6 @@ const i18nMap = {
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::Voice': 'VOICE',
};
const twilioChannelName = () => {
@@ -45,6 +48,9 @@ const readableChannelName = computed(() => {
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
}
if (props.channelType === 'Channel::TwilioSms') {
if (props.voiceEnabled) {
return t('INBOX_MGMT.CHANNELS.VOICE');
}
return twilioChannelName();
}
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
@@ -208,24 +208,6 @@ export default {
</NextButton>
</SettingsFieldSection>
</div>
<div v-else-if="isAVoiceChannel">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div v-else-if="isALineChannel">
<SettingsFieldSection
@@ -0,0 +1,156 @@
<script>
import { useAlert } from 'dashboard/composables';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SettingsFieldSection,
SettingsToggleSection,
NextInput,
NextButton,
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
voiceEnabled: this.inbox.voice_enabled || false,
apiKeySid: this.inbox.api_key_sid || '',
apiKeySecret: '',
isUpdating: false,
};
},
computed: {
isVoiceConfigured() {
return !!this.inbox.voice_configured;
},
hasApiKeySid() {
return !!this.inbox.api_key_sid;
},
hasExistingCredentials() {
return this.hasApiKeySid && !!this.inbox.has_api_key_secret;
},
needsCredentials() {
return (
this.voiceEnabled &&
!this.isVoiceConfigured &&
!this.hasExistingCredentials
);
},
needsApiKeySid() {
return this.needsCredentials && !this.hasApiKeySid;
},
isSubmitDisabled() {
if (!this.voiceEnabled) return false;
if (this.needsCredentials) {
if (this.needsApiKeySid && !this.apiKeySid) return true;
return !this.apiKeySecret;
}
return false;
},
},
watch: {
'inbox.voice_enabled'(val) {
this.voiceEnabled = val || false;
},
'inbox.api_key_sid'(val) {
this.apiKeySid = val || '';
},
},
methods: {
async updateVoiceSettings() {
this.isUpdating = true;
try {
const channelPayload = { voice_enabled: this.voiceEnabled };
if (this.needsCredentials) {
if (this.needsApiKeySid) {
channelPayload.api_key_sid = this.apiKeySid;
}
channelPayload.api_key_secret = this.apiKeySecret;
}
await this.$store.dispatch('inboxes/updateInbox', {
id: this.inbox.id,
formData: false,
channel: channelPayload,
});
this.apiKeySecret = '';
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isUpdating = false;
}
},
},
};
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsToggleSection
v-model="voiceEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
:description="
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
"
/>
<div v-if="voiceEnabled && needsCredentials" class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.VOICE_CONFIGURATION.CREDENTIALS.DESCRIPTION') }}
</p>
<NextInput
v-if="needsApiKeySid"
v-model="apiKeySid"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
/>
<NextInput
v-model="apiKeySecret"
type="password"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
"
/>
</div>
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')
"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div>
<NextButton
:disabled="isSubmitDisabled"
:is-loading="isUpdating"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="updateVoiceSettings"
/>
</div>
</div>
</template>
@@ -132,8 +132,10 @@ const headers = computed(() => [
</template>
</BaseTable>
<div class="flex items-center justify-between mt-4">
<p class="text-body-main text-n-slate-11">
<div
class="sticky bottom-0 py-4 px-8 -mx-8 z-20 flex items-center justify-between bg-n-surface-1 border-t border-n-weak"
>
<p class="text-body-main text-n-slate-11 mb-0">
{{
$t('TEAMS_SETTINGS.AGENTS.SELECTED_COUNT', {
selected: selectedAgents.length,
@@ -88,7 +88,7 @@ export default {
</script>
<template>
<div class="h-full w-full p-8 col-span-6 overflow-auto">
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
<PageHeader
:header-title="headerTitle"
@@ -103,7 +103,7 @@ export default {
</script>
<template>
<div class="h-full w-full p-8 col-span-6 overflow-auto">
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
<PageHeader
:header-title="headerTitle"
@@ -46,8 +46,8 @@
* 2. Nested properties in additional_attributes (browser_language, referer, etc.)
* 3. Nested properties in custom_attributes (conversation_type, etc.)
*/
import jsonLogic from 'json-logic-js';
import { coerceToDate } from '@chatwoot/utils';
import jsonLogic from 'json-logic-js';
/**
* Gets a value from a conversation based on the attribute key
@@ -121,7 +121,8 @@ const resolveValue = candidate => {
* @returns {Boolean} - Returns true if the values are considered equal according to filtering rules
*
* This function handles various equality scenarios:
* 1. When both values are arrays: checks if all items in filterValue exist in conversationValue
* 1. When both values are arrays (e.g. labels): matches if any filter value exists in the conversation array
* (mirrors the backend SQL `tag_id IN (...)` OR semantics)
* 2. When filterValue is an array but conversationValue is not: checks if conversationValue is included in filterValue
* 3. Otherwise: performs strict equality comparison
*/
@@ -131,8 +132,9 @@ const equalTo = (filterValue, conversationValue) => {
if (filterValue === 'all') return true;
if (Array.isArray(conversationValue)) {
// For array values like labels, check if any of the filter values exist in the array
return filterValue.every(val => conversationValue.includes(val));
// For array values like labels, match if any filter value is present.
// Mirrors the backend SQL `tag_id IN (...)` (OR semantics).
return filterValue.some(val => conversationValue.includes(val));
}
if (!Array.isArray(conversationValue)) {
@@ -416,6 +416,40 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Multi-label equal_to uses OR semantics to mirror the backend SQL `tag_id IN (...)`:
// a conversation matches if ANY of the filter labels is on it.
it('should match conversation with equal_to operator when any of multiple filter labels is present', () => {
const conversation = { labels: ['support'] };
const filters = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: [
{ id: 'support', name: 'Support' },
{ id: 'urgent', name: 'Urgent' },
],
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should not match conversation with equal_to operator when none of multiple filter labels is present', () => {
const conversation = { labels: ['new'] };
const filters = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: [
{ id: 'support', name: 'Support' },
{ id: 'urgent', name: 'Urgent' },
],
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match conversation with is_present operator for labels', () => {
const conversation = { labels: ['support', 'urgent', 'new'] };
const filters = [
+20 -1
View File
@@ -42,6 +42,19 @@ const updateAuthCookie = (cookieContent, baseDomain = '') =>
baseDomain,
});
const getTargetOrigin = () => {
const { baseUrl } = window.$chatwoot || {};
if (!baseUrl) {
return window.location.origin;
}
try {
const url = new URL(baseUrl);
return url.origin;
} catch {
return window.location.origin;
}
};
const updateCampaignReadStatus = baseDomain => {
const expireBy = addHours(new Date(), 1);
setCookieWithDomain('cw_snooze_campaigns_till', Number(expireBy), {
@@ -93,13 +106,19 @@ export const IFrameHelper = {
getBubbleHolder: () => document.getElementsByClassName('woot--bubble-holder'),
sendMessage: (key, value) => {
const element = IFrameHelper.getAppFrame();
const targetOrigin = getTargetOrigin();
if (!targetOrigin) return;
element.contentWindow.postMessage(
`chatwoot-widget:${JSON.stringify({ event: key, ...value })}`,
'*'
targetOrigin
);
},
initPostMessageCommunication: () => {
window.onmessage = e => {
const expectedOrigin = getTargetOrigin();
if (!expectedOrigin || e.origin !== expectedOrigin) {
return;
}
if (
typeof e.data !== 'string' ||
e.data.indexOf('chatwoot-widget:') !== 0
+3 -3
View File
@@ -1,4 +1,4 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
@@ -59,8 +59,8 @@ export default {
isALineChannel() {
return this.channelType === INBOX_TYPES.LINE;
},
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
voiceCallEnabled() {
return isVoiceCallEnabled(this.inbox);
},
isAnEmailChannel() {
return this.channelType === INBOX_TYPES.EMAIL;
+7 -2
View File
@@ -4,6 +4,7 @@
#
# id :bigint not null, primary key
# account_sid :string not null
# api_key_secret :string
# api_key_sid :string
# auth_token :string not null
# content_templates :jsonb
@@ -11,6 +12,8 @@
# medium :integer default("sms")
# messaging_service_sid :string
# phone_number :string
# twiml_app_sid :string
# voice_enabled :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
@@ -58,8 +61,6 @@ class Channel::TwilioSms < ApplicationRecord
client.messages.create(**params)
end
private
def client
if api_key_sid.present?
Twilio::REST::Client.new(api_key_sid, auth_token, account_sid)
@@ -68,6 +69,8 @@ class Channel::TwilioSms < ApplicationRecord
end
end
private
def send_message_from
if messaging_service_sid?
{ messaging_service_sid: messaging_service_sid }
@@ -76,3 +79,5 @@ class Channel::TwilioSms < ApplicationRecord
end
end
end
Channel::TwilioSms.prepend_mod_with('Channel::TwilioSms')
@@ -0,0 +1,66 @@
class Whatsapp::LiquidTemplateProcessorService
LIQUID_EXPRESSION = /\{\{\s*(.+?)\s*\}\}/
module JsonEscapeFilter
def json_escape(input)
input.to_s.to_json[1..-2]
end
end
pattr_initialize [:campaign!, :contact!]
def process_template_params(template_params)
return template_params if template_params.blank?
template_params_copy = template_params.deep_dup
processed_params = template_params_copy['processed_params']
return template_params_copy if processed_params.blank?
rendered_params = render_liquid(processed_params)
return nil if blank_render?(processed_params, rendered_params)
template_params_copy.merge('processed_params' => rendered_params)
end
private
def render_liquid(processed_params)
raw = processed_params.to_json
rewritten = raw.gsub(LIQUID_EXPRESSION) { "{{ #{Regexp.last_match(1)} | json_escape }}" }
rendered = Liquid::Template.parse(rewritten).render!(drops, filters: [JsonEscapeFilter])
JSON.parse(rendered)
rescue Liquid::Error, JSON::ParserError
processed_params
end
def drops
{
'contact' => ContactDrop.new(contact),
'agent' => UserDrop.new(campaign.sender),
'inbox' => InboxDrop.new(campaign.inbox),
'account' => AccountDrop.new(campaign.account)
}
end
def blank_render?(original, rendered)
case original
when Hash then blank_render_in_hash?(original, rendered)
when Array then blank_render_in_array?(original, rendered)
when String then original.match?(LIQUID_EXPRESSION) && rendered.to_s.blank?
else false
end
end
def blank_render_in_hash?(original, rendered)
return false unless rendered.is_a?(Hash)
original.any? { |key, value| blank_render?(value, rendered[key]) }
end
def blank_render_in_array?(original, rendered)
return false unless rendered.is_a?(Array)
original.each_with_index.any? { |value, index| blank_render?(value, rendered[index]) }
end
end
@@ -58,7 +58,10 @@ class Whatsapp::OneoffCampaignService
return
end
send_whatsapp_template_message(to: contact.phone_number)
processed_template_params = process_liquid_template_params(contact)
return if processed_template_params.nil?
send_whatsapp_template_message(to: contact.phone_number, template_params: processed_template_params)
end
def process_audience(audience_labels)
@@ -70,10 +73,22 @@ class Whatsapp::OneoffCampaignService
Rails.logger.info "Campaign #{campaign.id} processing completed"
end
def send_whatsapp_template_message(to:)
def process_liquid_template_params(contact)
liquid_processor = Whatsapp::LiquidTemplateProcessorService.new(campaign: campaign, contact: contact)
processed_template_params = liquid_processor.process_template_params(campaign.template_params)
Rails.logger.info "Skipping contact #{contact.name} - liquid variables resolved to blank values" if processed_template_params.nil?
processed_template_params
rescue StandardError => e
Rails.logger.error "Failed to process liquid template params for contact #{contact.name}: #{e.message}"
nil
end
def send_whatsapp_template_message(to:, template_params:)
processor = Whatsapp::TemplateProcessorService.new(
channel: channel,
template_params: campaign.template_params
template_params: template_params
)
name, namespace, lang_code, processed_parameters = processor.call
+10 -4
View File
@@ -72,6 +72,7 @@ if resource.twilio?
if Current.account_user&.administrator?
json.auth_token resource.channel.try(:auth_token)
json.account_sid resource.channel.try(:account_sid)
json.api_key_sid resource.channel.try(:api_key_sid)
end
end
@@ -131,8 +132,13 @@ if resource.whatsapp?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
end
## Voice Channel Attributes
if resource.channel_type == 'Channel::Voice'
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
## Voice attributes for TwilioSms
if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
json.voice_enabled resource.channel.voice_enabled?
json.voice_configured resource.channel.try(:twiml_app_sid).present?
json.has_api_key_secret resource.channel.try(:api_key_secret).present?
if resource.channel.try(:twiml_app_sid).present?
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
end
end
+3 -3
View File
@@ -191,10 +191,10 @@
- name: assignment_v2
display_name: Assignment V2
enabled: true
- name: twilio_content_templates
display_name: Twilio Content Templates
- name: captain_document_auto_sync
display_name: Captain Document Auto Sync
enabled: false
deprecated: true
premium: true
- name: advanced_search
display_name: Advanced Search
enabled: false
+5
View File
@@ -40,6 +40,11 @@ Rails.application.reloader.to_prepare do
if File.exist?(schedule_file) && Sidekiq.server?
schedule = YAML.load_file(schedule_file)
# Merge enterprise-only cron entries when running an enterprise build.
# Mirrors the conditional-load pattern already used for enterprise initializers.
enterprise_schedule_file = Rails.root.join('enterprise/config/schedule.yml')
schedule.merge!(YAML.load_file(enterprise_schedule_file)) if ChatwootApp.enterprise? && enterprise_schedule_file.exist?
# Cron entries removed from schedule.yml but possibly still in Redis
# with source:'dynamic' (predating the source tag). load_from_hash!
# only cleans up source:'schedule' entries, so these need explicit removal.
+3
View File
@@ -379,6 +379,9 @@ en:
limit_exceeded: 'Document limit exceeded'
pdf_format_error: 'must be a PDF file'
pdf_size_error: 'must be less than 10MB'
sync_not_supported_for_pdf: 'Sync is not supported for PDF documents'
sync_only_available_documents: 'Sync is only available for processed documents'
sync_already_in_progress: 'Document sync is already in progress'
pdf_upload_failed: 'Failed to upload PDF to OpenAI'
pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
+3 -1
View File
@@ -77,7 +77,9 @@ Rails.application.routes.draw do
resources :custom_tools do
post :test, on: :collection
end
resources :documents, only: [:index, :show, :create, :destroy]
resources :documents, only: [:index, :show, :create, :destroy] do
post :sync, on: :member
end
resource :tasks, only: [], controller: 'tasks' do
post :rewrite
post :summarize
@@ -0,0 +1,7 @@
class AddVoiceToChannelTwilioSms < ActiveRecord::Migration[7.0]
def change
add_column :channel_twilio_sms, :voice_enabled, :boolean, default: false, null: false
add_column :channel_twilio_sms, :twiml_app_sid, :string
add_column :channel_twilio_sms, :api_key_secret, :string
end
end
@@ -0,0 +1,20 @@
class DropChannelVoice < ActiveRecord::Migration[7.0]
def up
drop_table :channel_voice, if_exists: true
end
def down
create_table :channel_voice do |t|
t.string :phone_number, null: false
t.string :provider, null: false, default: 'twilio'
t.jsonb :provider_config, null: false
t.integer :account_id, null: false
t.jsonb :additional_attributes, default: {}
t.timestamps
end
add_index :channel_voice, :phone_number, unique: true
add_index :channel_voice, :account_id
end
end
@@ -0,0 +1,22 @@
class RepurposeTwilioContentTemplatesFlagForCaptainDocumentAutoSync < ActiveRecord::Migration[7.1]
def up
# The twilio_content_templates flag (deprecated) has been renamed to captain_document_auto_sync.
# Disable it on any accounts that had twilio_content_templates enabled so the repurposed
# flag starts in its intended default-off state.
Account.feature_captain_document_auto_sync.find_each(batch_size: 100) do |account|
account.disable_features(:captain_document_auto_sync)
account.save!(validate: false)
end
# Remove the stale twilio_content_templates entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
# ConfigLoader only adds new flags; it never removes renamed ones.
# Leaving it would cause NoMethodError in enable_default_features when
# creating new accounts (feature_twilio_content_templates= no longer exists).
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
return if config&.value.blank?
config.value = config.value.reject { |f| f['name'] == 'twilio_content_templates' }
config.save!
GlobalConfig.clear_cache
end
end
@@ -0,0 +1,18 @@
class BackfillCaptainDocumentSyncMetadata < ActiveRecord::Migration[7.0]
def up
return unless ChatwootApp.enterprise?
# rubocop:disable Rails/SkipsModelValidations
Captain::Document
.syncable
.where(status: :available, sync_status: nil, last_synced_at: nil)
.in_batches(of: 1000) do |batch|
batch.update_all('sync_status = 1, last_synced_at = updated_at')
end
# rubocop:enable Rails/SkipsModelValidations
end
def down
# No-op. This is a one-time baseline for existing available web documents.
end
end
+4 -13
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_27_094500) do
ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -552,6 +552,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.string "api_key_sid"
t.jsonb "content_templates", default: {}
t.datetime "content_templates_last_updated"
t.boolean "voice_enabled", default: false, null: false
t.string "twiml_app_sid"
t.string "api_key_secret"
t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true
t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
@@ -568,18 +571,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
end
create_table "channel_voice", force: :cascade do |t|
t.string "phone_number", null: false
t.string "provider", default: "twilio", null: false
t.jsonb "provider_config", null: false
t.integer "account_id", null: false
t.jsonb "additional_attributes", default: {}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_channel_voice_on_account_id"
t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
end
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
t.string "website_url"
t.integer "account_id"
@@ -2,13 +2,13 @@ module Enterprise::ContactInboxBuilder
private
def generate_source_id
return super unless @inbox.channel_type == 'Channel::Voice'
return super unless twilio_voice_inbox?
phone_source_id
end
def phone_source_id
return super unless @inbox.channel_type == 'Channel::Voice'
return super unless twilio_voice_inbox?
return SecureRandom.uuid if @contact.phone_number.blank?
@@ -16,6 +16,10 @@ module Enterprise::ContactInboxBuilder
end
def allowed_channels?
super || @inbox.channel_type == 'Channel::Voice'
super || twilio_voice_inbox?
end
def twilio_voice_inbox?
@inbox.channel_type == 'Channel::TwilioSms' && @inbox.channel.voice_enabled?
end
end
@@ -2,8 +2,13 @@ module Enterprise::Messages::MessageBuilder
private
def message_type
return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call'
super
end
def twilio_voice_inbox?
inbox = @conversation.inbox
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
end
end
@@ -7,7 +7,10 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze
def create
@responses = process_bulk_action
result = process_bulk_action
return if performed?
@responses = result
end
private
@@ -15,13 +18,13 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
def validate_params
return if params[:type].present? && params[:ids].present? && params[:fields].present?
render json: { success: false }, status: :unprocessable_entity
render json: { success: false }, status: :unprocessable_content
end
def type_matches?
return if MODEL_TYPE.include?(params[:type])
render json: { success: false }, status: :unprocessable_entity
render json: { success: false }, status: :unprocessable_content
end
def process_bulk_action
@@ -48,13 +51,38 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
end
def handle_documents
return [] unless params[:fields][:status] == 'delete'
case params[:fields][:status]
when 'delete'
delete_documents
when 'sync'
sync_documents
else
[]
end
end
def delete_documents
documents = Current.account.captain_documents.where(id: params[:ids])
return [] unless documents.exists?
return render json: { count: 0 } unless documents.exists?
documents.destroy_all
[]
destroyed_documents = documents.destroy_all
render json: { count: destroyed_documents.size }
end
def sync_documents
synced_document_ids = []
Current.account.captain_documents.where(id: params[:ids]).find_each(batch_size: 100) do |document|
next unless document.syncable?
next unless document.available?
next if document.sync_in_progress?
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
Captain::Documents::PerformSyncJob.perform_later(document)
synced_document_ids << document.id
end
render json: { ids: synced_document_ids, count: synced_document_ids.size }
end
def permitted_params
@@ -4,7 +4,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
before_action :set_current_page, only: [:index]
before_action :set_documents, except: [:create]
before_action :set_document, only: [:show, :destroy]
before_action :set_document, only: [:show, :destroy, :sync]
before_action :set_assistant, only: [:create]
RESULTS_PER_PAGE = 25
@@ -29,6 +29,16 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
render_could_not_create_error(e.record.errors.full_messages.join(', '))
end
def sync
return render_could_not_create_error(I18n.t('captain.documents.sync_not_supported_for_pdf')) unless @document.syncable?
return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available?
return render_could_not_create_error(I18n.t('captain.documents.sync_already_in_progress')) if @document.sync_in_progress?
@document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
Captain::Documents::PerformSyncJob.perform_later(@document)
head :accepted
end
def destroy
@document.destroy
head :no_content
@@ -30,9 +30,14 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
end
def voice_inbox
@voice_inbox ||= Current.user.assigned_inboxes.where(
account_id: Current.account.id,
channel_type: 'Channel::Voice'
).find(params.require(:inbox_id))
@voice_inbox ||= begin
inbox = Current.user.assigned_inboxes.where(
account_id: Current.account.id,
channel_type: 'Channel::TwilioSms'
).find(params.require(:inbox_id))
raise ActiveRecord::RecordNotFound, 'Voice not enabled' unless inbox.channel.voice_enabled?
inbox
end
end
end
@@ -14,20 +14,46 @@ module Enterprise::Api::V1::Accounts::InboxesController
end
def channel_type_from_params
case permitted_params[:channel][:type]
when 'voice'
Channel::Voice
else
super
end
return Channel::TwilioSms if permitted_params[:channel][:type] == 'voice'
super
end
def account_channels_method
case permitted_params[:channel][:type]
when 'voice'
Current.account.voice_channels
else
super
end
return Current.account.twilio_sms if permitted_params[:channel][:type] == 'voice'
super
end
def create_channel
return create_voice_channel if permitted_params[:channel][:type] == 'voice'
super
end
def get_channel_attributes(channel_type)
attrs = super
attrs += [:voice_enabled, :api_key_sid, :api_key_secret] if channel_type == 'Channel::TwilioSms' && @inbox&.channel&.medium == 'sms'
attrs
end
def create_voice_channel
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('channel_voice')
voice_params = params.require(:channel).permit(
:phone_number, :provider,
provider_config: [:account_sid, :auth_token, :api_key_sid, :api_key_secret]
)
config = voice_params[:provider_config] || {}
Current.account.twilio_sms.create!(
phone_number: voice_params[:phone_number],
account_sid: config[:account_sid],
auth_token: config[:auth_token],
api_key_sid: config[:api_key_sid],
api_key_secret: config[:api_key_secret],
medium: :sms,
voice_enabled: true
)
end
end
@@ -169,8 +169,10 @@ class Twilio::VoiceController < ApplicationController
def set_inbox!
digits = params[:phone].to_s.gsub(/\D/, '')
e164 = "+#{digits}"
channel = Channel::Voice.find_by!(phone_number: e164)
phone_number = "+#{digits}"
channel = Channel::TwilioSms.find_by!(phone_number: phone_number)
raise ActiveRecord::RecordNotFound, "Voice not enabled for #{phone_number}" unless channel.voice_enabled?
@inbox = channel.inbox
end
@@ -1,6 +1,9 @@
class Captain::Documents::PerformSyncJob < MutexApplicationJob
queue_as :low
# A single page fetch + fingerprint compare should complete in seconds.
# 10 minutes is generous headroom — if still "syncing" after that, the worker likely died mid-run.
# Shared with ScheduleSyncsJob so stale locks are re-enqueued at the same threshold.
LOCK_TIMEOUT = 10.minutes
# Safety net for anything we didn't rescue by name — parser bugs, ActiveRecord blips,
@@ -78,6 +81,7 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
def handle_unexpected_failure(document, error, start_time)
document.update!(
sync_status: :failed,
sync_step: nil,
last_sync_error_code: 'sync_error',
last_sync_attempted_at: Time.current
)
@@ -0,0 +1,65 @@
class Captain::Documents::ScheduleSyncsJob < ApplicationJob
queue_as :scheduled_jobs
PER_ACCOUNT_HOURLY_CAP = 50
GLOBAL_HOURLY_CAP = 1000
SYNC_STALE_TIMEOUT = Captain::Document::SYNC_STALE_TIMEOUT
def perform
@remaining_global_capacity = GLOBAL_HOURLY_CAP
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
break if @remaining_global_capacity <= 0
stats[:accounts_scanned] += 1
next unless account.feature_enabled?('captain_document_auto_sync')
stats[:accounts_enabled] += 1
interval = account.captain_document_sync_interval
next unless interval
stats[:accounts_scheduled] += 1
stats[:documents_enqueued] += enqueue_due_documents(account, interval)
end
log_scheduler_summary(stats)
end
private
def enqueue_due_documents(account, interval)
syncing = Captain::Document.sync_statuses[:syncing]
synced = Captain::Document.sync_statuses[:synced]
failed = Captain::Document.sync_statuses[:failed]
stale_cutoff = SYNC_STALE_TIMEOUT.ago
per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min
enqueued_count = 0
account.captain_documents.syncable.where(status: :available).where(
'(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \
'(sync_status = ? AND last_sync_attempted_at < ?)',
synced, interval.ago, failed, interval.ago, syncing, stale_cutoff
).order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id).limit(per_account_limit).each do |document|
next unless document.syncable?
# Reserve the sync slot before enqueueing so later scheduler runs skip this document while the job is queued.
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
Captain::Documents::PerformSyncJob.perform_later(document)
@remaining_global_capacity -= 1
enqueued_count += 1
end
enqueued_count
end
def log_scheduler_summary(stats)
payload = {
event: 'completed',
global_cap_hit: @remaining_global_capacity <= 0,
remaining_global_capacity: @remaining_global_capacity
}.merge(stats)
Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
end
end
@@ -14,7 +14,11 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
external_link: canonical_url,
content: payload[:markdown],
name: metadata['title'],
status: :available
status: :available,
sync_status: :synced,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
)
rescue StandardError => e
raise "Failed to parse FireCrawl data: #{e.message}"
@@ -1,6 +1,10 @@
class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
class PermanentCrawlError < StandardError; end
queue_as :low
discard_on PermanentCrawlError
def perform(assistant_id:, page_link:)
assistant = Captain::Assistant.find(assistant_id)
account = assistant.account
@@ -11,23 +15,71 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
end
crawler = Captain::Tools::SimplePageCrawlService.new(page_link)
page_title = crawler.page_title || ''
content = crawler.body_text_content || ''
normalized_link = normalize_link(page_link)
document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
document.update!(
external_link: normalized_link,
name: page_title[0..254], content: content[0..14_999], status: :available
)
handle_failed_fetch!(document, crawler.status_code, page_link) unless crawler.success?
persist_document!(document, normalized_link, crawler)
rescue PermanentCrawlError
raise
rescue StandardError => e
raise "Failed to parse data: #{page_link} #{e.message}"
end
private
def handle_failed_fetch!(document, status_code, page_link)
error_code = http_error_code(status_code)
mark_failed!(document, error_code) if document.persisted?
error_message = "Failed to fetch page: #{page_link}"
raise PermanentCrawlError, error_message if permanent_failure?(error_code)
raise error_message
end
def persist_document!(document, normalized_link, crawler)
document.update!(
external_link: normalized_link,
name: (crawler.page_title || '')[0..254],
content: (crawler.body_markdown || '')[0..14_999],
status: :available,
**synced_attributes
)
end
def synced_attributes
{
sync_status: :synced,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
}
end
def mark_failed!(document, error_code)
document.update!(
status: :available,
sync_status: :failed,
last_sync_error_code: error_code,
last_sync_attempted_at: Time.current
)
end
def http_error_code(status_code)
case status_code
when 404 then 'not_found'
when 401, 403 then 'access_denied'
when 408, 504 then 'timeout'
else 'fetch_failed'
end
end
def permanent_failure?(error_code)
Captain::Documents::SyncService::PERMANENT_ERROR_CODES.include?(error_code)
end
def normalize_link(raw_link)
raw_link.to_s.delete_suffix('/')
end
+16 -29
View File
@@ -26,12 +26,14 @@
#
class Captain::Document < ApplicationRecord
class LimitExceededError < StandardError; end
SYNC_STALE_TIMEOUT = 2.hours
self.table_name = 'captain_documents'
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
belongs_to :account
has_one_attached :pdf_file
store_accessor :metadata, :content_fingerprint, :last_sync_error_code, :sync_step, :openai_file_id
validates :external_link, presence: true, unless: -> { pdf_file.attached? }
validates :external_link, uniqueness: { scope: :assistant_id }, allow_blank: true
@@ -59,6 +61,7 @@ class Captain::Document < ApplicationRecord
scope :for_account, ->(account_id) { where(account_id: account_id) }
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
scope :syncable, -> { where("external_link NOT LIKE 'PDF:%' AND external_link NOT LIKE '%.pdf'") }
def pdf_document?
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
@@ -75,36 +78,8 @@ class Captain::Document < ApplicationRecord
pdf_file.blob.byte_size if pdf_file.attached?
end
def content_fingerprint
metadata&.dig('content_fingerprint')
end
def content_fingerprint=(value)
self.metadata = (metadata || {}).merge('content_fingerprint' => value)
end
def last_sync_error_code
metadata&.dig('last_sync_error_code')
end
def last_sync_error_code=(value)
self.metadata = (metadata || {}).merge('last_sync_error_code' => value)
end
def sync_step
metadata&.dig('sync_step')
end
def store_sync_step(step)
update!(metadata: (metadata || {}).merge('sync_step' => step))
end
def openai_file_id
metadata&.dig('openai_file_id')
end
def store_openai_file_id(file_id)
update!(metadata: (metadata || {}).merge('openai_file_id' => file_id))
update!(openai_file_id: file_id)
end
def display_url
@@ -121,6 +96,18 @@ class Captain::Document < ApplicationRecord
{ document_id: id, assistant_id: assistant_id, external_link: external_link }
end
def syncable?
!pdf_document?
end
def sync_stale?
sync_syncing? && (last_sync_attempted_at.blank? || last_sync_attempted_at < SYNC_STALE_TIMEOUT.ago)
end
def sync_in_progress?
sync_syncing? && !sync_stale?
end
private
def enqueue_crawl_job
-122
View File
@@ -1,122 +0,0 @@
# == Schema Information
#
# Table name: channel_voice
#
# id :bigint not null, primary key
# additional_attributes :jsonb
# phone_number :string not null
# provider :string default("twilio"), not null
# provider_config :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_channel_voice_on_account_id (account_id)
# index_channel_voice_on_phone_number (phone_number) UNIQUE
#
class Channel::Voice < ApplicationRecord
include Channelable
self.table_name = 'channel_voice'
validates :phone_number, presence: true, uniqueness: true
validates :provider, presence: true
validates :provider_config, presence: true
# Validate phone number format (E.164 format)
validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ }
# Provider-specific configs stored in JSON
validate :validate_provider_config
before_validation :provision_twilio_on_create, on: :create, if: :twilio?
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
def name
"Voice (#{phone_number})"
end
def messaging_window_enabled?
false
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
case provider
when 'twilio'
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
conference_sid: conference_sid,
agent_id: agent_id
)
else
raise "Unsupported voice provider: #{provider}"
end
end
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
end
private
def twilio?
provider == 'twilio'
end
def validate_provider_config
return if provider_config.blank?
case provider
when 'twilio'
validate_twilio_config
end
end
def validate_twilio_config
config = provider_config.with_indifferent_access
# Require credentials and provisioned TwiML App SID
required_keys = %w[account_sid auth_token api_key_sid api_key_secret twiml_app_sid]
required_keys.each do |key|
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
def provider_config_hash
if provider_config.is_a?(Hash)
provider_config
else
JSON.parse(provider_config.to_s)
end
end
def provision_twilio_on_create
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
app_sid = service.perform
return if app_sid.blank?
cfg = provider_config.with_indifferent_access
cfg[:twiml_app_sid] = app_sid
self.provider_config = cfg
rescue StandardError => e
error_details = {
error_class: e.class.to_s,
message: e.message,
phone_number: phone_number,
account_id: account_id,
backtrace: e.backtrace&.first(5)
}
Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}")
errors.add(:base, "Twilio setup failed: #{e.message}")
end
public :provider_config_hash
end
@@ -1,4 +1,11 @@
module Enterprise::Account
CAPTAIN_SYNC_INTERVALS = {
'hacker' => nil,
'startups' => 7.days,
'business' => 1.day,
'enterprise' => 6.hours
}.freeze
# TODO: Remove this when we upgrade administrate gem to the latest version
# this is a temporary method since current administrate doesn't support virtual attributes
def manually_managed_features; end
@@ -34,6 +41,14 @@ module Enterprise::Account
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
end
def captain_document_sync_interval
plan = custom_attributes['plan_name']
plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
return nil if plan.blank?
CAPTAIN_SYNC_INTERVALS[plan.downcase]
end
def saml_enabled?
saml_settings&.saml_enabled? || false
end
@@ -0,0 +1,81 @@
module Enterprise::Channel::TwilioSms
extend ActiveSupport::Concern
def self.prepended(base)
base.class_eval do
encrypts :api_key_secret if Chatwoot.encryption_configured?
validate :voice_requires_phone_number, if: :voice_enabled?
before_validation :provision_twiml_app, on: :create, if: :voice_enabled?
before_validation :provision_twiml_app_on_update, on: :update, if: :voice_enabled_changed_to_true?
after_commit :teardown_voice, on: :update, if: :voice_disabled?
end
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
conference_sid: conference_sid,
agent_id: agent_id
)
end
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
end
# Voice channels store the secret in api_key_secret; SMS channels keep using auth_token via super.
def client
if api_key_sid.present? && api_key_secret.present?
Twilio::REST::Client.new(api_key_sid, api_key_secret, account_sid)
else
super
end
end
private
def voice_requires_phone_number
return if phone_number.present?
errors.add(:base, 'Voice calling requires a phone number and cannot be used with messaging service SID')
end
def voice_enabled_changed_to_true?
voice_enabled? && voice_enabled_changed?
end
def voice_disabled?
!voice_enabled? && voice_enabled_previously_changed?
end
def teardown_voice
Twilio::VoiceTeardownService.new(channel: self).perform
end
def provision_twiml_app
return if twiml_app_sid.present?
return if phone_number.blank?
validate_voice_capability!
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
self.twiml_app_sid = service.perform
rescue StandardError => e
Rails.logger.error("TWILIO_VOICE_SETUP_ERROR: #{e.class} #{e.message} phone=#{phone_number} account=#{account_id}")
errors.add(:base, "Twilio voice setup failed: #{e.message}")
end
def validate_voice_capability!
number = client.incoming_phone_numbers.list(phone_number: phone_number).first
raise 'Phone number not found in Twilio account' unless number
raise 'This phone number does not support voice calls' unless number.capabilities['voice']
end
alias provision_twiml_app_on_update provision_twiml_app
end
@@ -16,7 +16,6 @@ module Enterprise::Concerns::Account
has_many :copilot_threads, dependent: :destroy_async
has_many :companies, dependent: :destroy_async
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
has_many :calls, dependent: :destroy_async
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
@@ -23,6 +23,10 @@ class Captain::AssistantPolicy < ApplicationPolicy
@account_user.administrator?
end
def sync?
@account_user.administrator?
end
def playground?
true
end
@@ -20,7 +20,7 @@ class Captain::Documents::SinglePageFetcher
private
def firecrawl_configured?
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
Captain::Tools::FirecrawlService.configured?
end
def fetch_with_firecrawl
@@ -52,14 +52,13 @@ class Captain::Documents::SinglePageFetcher
end
def fetch_with_fallback
response = HTTParty.get(@url)
return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
crawler = Captain::Tools::SimplePageCrawlService.new(@url)
return Result.new(success: false, error_code: http_error_code(crawler.status_code)) unless crawler.success?
parser = Captain::Tools::HtmlPageParser.new(response.body)
Result.new(
success: true,
title: parser.title&.truncate(TITLE_MAX_LENGTH, omission: ''),
content: parser.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
title: crawler.page_title&.truncate(TITLE_MAX_LENGTH, omission: ''),
content: crawler.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
)
end
@@ -12,7 +12,7 @@ class Captain::Documents::SyncService
end
def perform
@document.store_sync_step('fetching')
@document.update!(sync_step: 'fetching')
result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch
unless result.success
@@ -20,17 +20,21 @@ class Captain::Documents::SyncService
raise_for_error_code(result.error_code)
end
@document.store_sync_step('comparing')
fingerprint = compute_fingerprint(result.content)
@document.update!(sync_step: 'comparing')
new_fingerprint = compute_fingerprint(result.content)
previous_fingerprint = @document.content_fingerprint
if fingerprint == @document.content_fingerprint
if new_fingerprint == previous_fingerprint
mark_synced
return :unchanged
end
@document.store_sync_step('updating')
update_content(result, fingerprint)
:updated
@document.update!(sync_step: 'updating')
update_content(result, new_fingerprint)
# Without a prior fingerprint we cannot tell a first-ever sync apart from a real
# change, so treat it as unchanged to keep downstream signals quiet on baseline.
previous_fingerprint.present? ? :updated : :unchanged
end
private
@@ -42,6 +46,7 @@ class Captain::Documents::SyncService
def mark_failed(error_code)
@document.update!(
sync_status: :failed,
sync_step: nil,
last_sync_error_code: error_code,
last_sync_attempted_at: Time.current
)
@@ -50,6 +55,7 @@ class Captain::Documents::SyncService
def mark_synced
@document.update!(
sync_status: :synced,
sync_step: nil,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
@@ -62,6 +68,7 @@ class Captain::Documents::SyncService
name: result.title.presence || @document.name,
content_fingerprint: fingerprint,
sync_status: :synced,
sync_step: nil,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
@@ -1,5 +1,6 @@
class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
include Integrations::LlmInstrumentation
MAX_CONTENT_LENGTH = 8000
def initialize(website_url)
@@ -30,7 +31,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
def fetch_website_content
crawler = Captain::Tools::SimplePageCrawlService.new(@website_url)
text_content = crawler.body_text_content
text_content = crawler.body_markdown
page_title = crawler.page_title
meta_description = crawler.meta_description
@@ -2,9 +2,14 @@ class Captain::Tools::FirecrawlService
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
def self.configured?
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value
.present?
end
def initialize
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
raise 'Missing API key' if @api_key.empty?
raise 'Missing API key' if @api_key.blank?
end
def perform(url, webhook_url, crawl_limit = 10)
@@ -1,12 +1,16 @@
class Captain::Tools::SimplePageCrawlService
attr_reader :external_link
attr_reader :external_link, :status_code
def initialize(external_link)
@external_link = external_link
@parser = Captain::Tools::HtmlPageParser.new(HTTParty.get(external_link).body)
@parser = Captain::Tools::HtmlPageParser.new(fetch_body)
@doc = @parser.doc
end
def success?
status_code.to_i.between?(200, 299)
end
def page_links
sitemap? ? extract_links_from_sitemap : extract_links_from_html
end
@@ -15,7 +19,7 @@ class Captain::Tools::SimplePageCrawlService
@parser.title
end
def body_text_content
def body_markdown
@parser.body_markdown
end
@@ -35,6 +39,21 @@ class Captain::Tools::SimplePageCrawlService
private
def fetch_body
body = ''
SafeFetch.fetch(external_link, validate_content_type: false) do |result|
body = result.tempfile.read
end
@status_code = 200
body
rescue SafeFetch::HttpError => e
@status_code = e.message.to_i
''
rescue SafeFetch::Error
@status_code = nil
''
end
def sitemap?
@external_link.end_with?('.xml')
end
@@ -1,9 +1,9 @@
module Enterprise::Contacts::ContactableInboxesService
private
# Extend base selection to include Voice inboxes
# Extend base selection to include voice-enabled TwilioSms inboxes
def get_contactable_inbox(inbox)
return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Voice'
return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
super
end
@@ -0,0 +1,33 @@
class Twilio::VoiceTeardownService
pattr_initialize [:channel!]
def perform
delete_twiml_app if channel.twiml_app_sid.present?
clear_number_webhooks
ensure
clear_voice_credentials
end
private
def delete_twiml_app
channel.client.applications(channel.twiml_app_sid).delete
rescue StandardError => e
Rails.logger.error("TWILIO_VOICE_TEARDOWN_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}")
end
def clear_number_webhooks
numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number)
return if numbers.empty?
channel.client
.incoming_phone_numbers(numbers.first.sid)
.update(voice_url: '', status_callback: '')
rescue StandardError => e
Rails.logger.error("TWILIO_VOICE_TEARDOWN_WEBHOOK_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}")
end
def clear_voice_credentials
channel.update(twiml_app_sid: nil)
end
end
@@ -17,8 +17,7 @@ class Twilio::VoiceWebhookSetupService
private
def validate_token_credentials!
# Only validate Account SID + Auth Token
token_client.incoming_phone_numbers.list(limit: 1)
channel.client.incoming_phone_numbers.list(limit: 1)
rescue StandardError => e
log_twilio_error('AUTH_VALIDATION_TOKEN', e)
raise
@@ -26,7 +25,7 @@ class Twilio::VoiceWebhookSetupService
def create_twiml_app!
friendly_name = "Chatwoot Voice #{channel.phone_number}"
app = api_key_client.applications.create(
app = channel.client.applications.create(
friendly_name: friendly_name,
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD
@@ -38,39 +37,25 @@ class Twilio::VoiceWebhookSetupService
end
def configure_number_webhooks!
numbers = api_key_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number)
if numbers.empty?
Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
return
end
api_key_client
.incoming_phone_numbers(numbers.first.sid)
.update(
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD,
status_callback: channel.voice_status_webhook_url,
status_callback_method: HTTP_METHOD
)
channel.client
.incoming_phone_numbers(numbers.first.sid)
.update(
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD,
status_callback: channel.voice_status_webhook_url,
status_callback_method: HTTP_METHOD
)
rescue StandardError => e
log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e)
raise
end
def api_key_client
@api_key_client ||= begin
cfg = channel.provider_config.with_indifferent_access
::Twilio::REST::Client.new(cfg[:api_key_sid], cfg[:api_key_secret], cfg[:account_sid])
end
end
def token_client
@token_client ||= begin
cfg = channel.provider_config.with_indifferent_access
::Twilio::REST::Client.new(cfg[:account_sid], cfg[:auth_token])
end
end
def log_twilio_error(context, error)
details = build_error_details(context, error)
add_twilio_specific_details(details, error)
@@ -80,11 +65,10 @@ class Twilio::VoiceWebhookSetupService
end
def build_error_details(context, error)
cfg = channel.provider_config.with_indifferent_access
{
context: context,
phone_number: channel.phone_number,
account_sid: cfg[:account_sid],
account_sid: channel.account_sid,
error_class: error.class.to_s,
message: error.message
}
@@ -43,10 +43,6 @@ class Voice::Provider::Twilio::Adapter
end
def twilio_client
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
def config
@config ||= @channel.provider_config_hash
Twilio::REST::Client.new(@channel.account_sid, @channel.auth_token)
end
end
@@ -1,5 +1,5 @@
class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!, { twilio_client: nil }]
pattr_initialize [:conversation!]
def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid')
@@ -19,10 +19,11 @@ class Voice::Provider::Twilio::ConferenceService
end
def end_conference
twilio_client
client = conversation.inbox.channel.client
client
.conferences
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
.each { |conf| client.conferences(conf.sid).update(status: 'completed') }
end
private
@@ -31,16 +32,4 @@ class Voice::Provider::Twilio::ConferenceService
current = conversation.additional_attributes || {}
conversation.update!(additional_attributes: current.merge(attrs))
end
def twilio_client
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
end
def account_sid
@account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid']
end
def auth_token
@auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token']
end
end
@@ -6,20 +6,20 @@ class Voice::Provider::Twilio::TokenService
token: access_token.to_jwt,
identity: identity,
voice_enabled: true,
account_sid: config['account_sid'],
account_sid: channel.account_sid,
agent_id: user.id,
account_id: account.id,
inbox_id: inbox.id,
phone_number: inbox.channel.phone_number,
phone_number: channel.phone_number,
twiml_endpoint: twiml_url,
has_twiml_app: config['twiml_app_sid'].present?
has_twiml_app: channel.twiml_app_sid.present?
}
end
private
def config
@config ||= inbox.channel.provider_config_hash || {}
def channel
@channel ||= inbox.channel
end
def identity
@@ -28,9 +28,9 @@ class Voice::Provider::Twilio::TokenService
def access_token
Twilio::JWT::AccessToken.new(
config['account_sid'],
config['api_key_sid'],
config['api_key_secret'],
channel.account_sid,
channel.api_key_sid,
channel.api_key_secret,
identity: identity,
ttl: 1.hour.to_i
).tap { |token| token.add_grant(voice_grant) }
@@ -39,7 +39,7 @@ class Voice::Provider::Twilio::TokenService
def voice_grant
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
grant.incoming_allow = true
grant.outgoing_application_sid = config['twiml_app_sid']
grant.outgoing_application_sid = channel.twiml_app_sid
grant.outgoing_application_params = outgoing_params
end
end
@@ -50,13 +50,13 @@ class Voice::Provider::Twilio::TokenService
agent_id: user.id,
identity: identity,
client_name: identity,
accountSid: config['account_sid'],
accountSid: channel.account_sid,
is_agent: 'true'
}
end
def twiml_url
digits = inbox.channel.phone_number.delete_prefix('+')
digits = channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
end
+10
View File
@@ -0,0 +1,10 @@
# Enterprise-only Sidekiq cron schedule.
# Loaded by config/initializers/sidekiq.rb only when ChatwootApp.enterprise? is true.
# Add cron entries here when the referenced job class lives under enterprise/.
# Captain document auto-sync scheduler
# Runs hourly, finds due documents based on plan sync intervals
captain_documents_schedule_syncs_job:
cron: '0 * * * *'
class: 'Captain::Documents::ScheduleSyncsJob'
queue: scheduled_jobs
@@ -19,7 +19,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
:captain_document,
2,
assistant: assistant,
account: account
account: account,
status: :available
)
end
@@ -115,7 +116,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
}
end
it 'deletes the documents and returns an empty array' do
it 'deletes the documents and returns the deleted count' do
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: document_delete_params,
@@ -124,7 +125,118 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
end.to change(Captain::Document, :count).by(-2)
expect(response).to have_http_status(:ok)
expect(json_response).to eq([])
expect(json_response).to eq({ count: documents.size })
end
end
context 'when syncing documents' do
let(:sync_params) do
{
type: 'AssistantDocument',
ids: documents.map(&:id),
fields: { status: 'sync' }
}
end
before { clear_enqueued_jobs }
it 'queues a sync for each web document and returns the enqueued document ids' do
freeze_time do
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params,
headers: admin.create_new_auth_token,
as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(documents.size).times
documents.each do |document|
expect(document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end
end
expect(response).to have_http_status(:ok)
expect(json_response).to eq({ ids: documents.map(&:id), count: documents.size })
end
it 'skips PDF documents because they are not syncable' do
pdf_document = build(:captain_document, assistant: assistant, account: account)
pdf_document.pdf_file.attach(io: StringIO.new('PDF content'), filename: 'test.pdf',
content_type: 'application/pdf')
pdf_document.save!
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params.merge(ids: [pdf_document.id]),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:ok)
end
it 'skips documents that are still being processed' do
in_progress_document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params.merge(ids: [in_progress_document.id]),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:ok)
end
it 'skips documents that already have a sync in progress' do
syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available)
syncing_document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params.merge(ids: [syncing_document.id]),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:ok)
end
it 'queues stale syncing documents again' do
syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available)
freeze_time do
syncing_document.update!(
sync_status: :syncing,
last_sync_attempted_at: (Captain::Document::SYNC_STALE_TIMEOUT + 1.minute).ago
)
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params.merge(ids: [syncing_document.id]),
headers: admin.create_new_auth_token,
as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(syncing_document)
expect(syncing_document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end
expect(response).to have_http_status(:ok)
expect(json_response).to eq({ ids: [syncing_document.id], count: 1 })
end
it 'denies non-administrators' do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: sync_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
@@ -6,7 +6,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:assistant2) { create(:captain_assistant, account: account) }
let(:document) { create(:captain_document, assistant: assistant, account: account) }
let(:document) { create(:captain_document, assistant: assistant, account: account, status: :available) }
let(:captain_limits) do
{
:startups => { :documents => 1, :responses => 100 }
@@ -141,6 +141,27 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect(json_response[:name]).to eq(document.name)
expect(json_response[:external_link]).to eq(document.external_link)
end
it 'returns sync metadata when the document has been synced' do
synced_at = 1.hour.ago
document.update!(sync_status: :synced, last_synced_at: synced_at)
get "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}",
headers: agent.create_new_auth_token, as: :json
expect(json_response[:sync_status]).to eq('synced')
expect(json_response[:last_synced_at]).to eq(synced_at.to_i)
end
it 'does not report failed documents without a successful sync as last synced' do
document.update!(sync_status: :failed, last_sync_attempted_at: 1.minute.ago)
get "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}",
headers: agent.create_new_auth_token, as: :json
expect(json_response[:sync_status]).to eq('failed')
expect(json_response[:last_synced_at]).to be_nil
end
end
end
@@ -235,6 +256,98 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
end
end
describe 'POST /api/v1/accounts/:account_id/captain/documents/:id/sync' do
before { clear_enqueued_jobs }
context 'when it is an un-authenticated user' do
it 'returns unauthorized status' do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'denies the request' do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
it 'queues a sync and returns accepted' do
freeze_time do
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
expect(document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end
expect(response).to have_http_status(:accepted)
end
it 'rejects documents that already have a sync in progress' do
document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'queues stale syncing documents again' do
freeze_time do
document.update!(sync_status: :syncing, last_sync_attempted_at: (Captain::Document::SYNC_STALE_TIMEOUT + 1.minute).ago)
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
expect(document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end
expect(response).to have_http_status(:accepted)
end
it 'rejects PDF documents with an explanatory error' do
pdf_document = build(:captain_document, assistant: assistant, account: account)
pdf_document.pdf_file.attach(io: StringIO.new('PDF content'), filename: 'test.pdf',
content_type: 'application/pdf')
pdf_document.save!
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{pdf_document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects documents that are still being processed' do
in_progress_document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/#{in_progress_document.id}/sync",
headers: admin.create_new_auth_token, as: :json
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'DELETE /api/v1/accounts/:account_id/captain/documents/:id' do
context 'when it is an un-authenticated user' do
before do
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
let(:account) { create(:account) }
let(:voice_channel) { create(:channel_voice, account: account) }
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:voice_inbox) { voice_channel.inbox }
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
let(:admin) { create(:user, :administrator, account: account) }
@@ -24,6 +24,11 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
end
it 'creates a voice inbox when administrator' do
account.enable_features('channel_voice')
account.save!
stub_request(:get, %r{api\.twilio\.com/2010-04-01/Accounts/.*/IncomingPhoneNumbers\.json})
.to_return(status: 200, body: { incoming_phone_numbers: [{ capabilities: { 'voice' => true } }] }.to_json,
headers: { 'Content-Type' => 'application/json' })
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService,
perform: "AP#{SecureRandom.hex(16)}"))
@@ -34,8 +39,7 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
auth_token: SecureRandom.hex(16),
api_key_sid: SecureRandom.hex(8),
api_key_secret: SecureRandom.hex(16),
twiml_app_sid: "AP#{SecureRandom.hex(16)}" } } },
api_key_secret: SecureRandom.hex(16) } } },
as: :json
expect(response).to have_http_status(:success)
@@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe 'Twilio::VoiceController', type: :request do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230003') }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230003') }
let(:inbox) { channel.inbox }
let(:digits) { channel.phone_number.delete_prefix('+') }
@@ -0,0 +1,217 @@
require 'rails_helper'
RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
let(:account) { create(:account, custom_attributes: { plan_name: 'business' }) }
let(:assistant) { create(:captain_assistant, account: account) }
before do
account.enable_features!('captain_document_auto_sync')
clear_enqueued_jobs
end
context 'when the account has not enabled auto-sync' do
before { account.disable_features!('captain_document_auto_sync') }
it 'leaves available documents alone' do
create(:captain_document, assistant: assistant, account: account, status: :available)
clear_enqueued_jobs
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
end
end
context 'when the account plan has no sync cadence' do
let(:account) { create(:account, custom_attributes: { plan_name: 'hacker' }) }
it 'leaves available documents alone' do
create(:captain_document, assistant: assistant, account: account, status: :available)
clear_enqueued_jobs
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
end
end
context 'when an available document has backfilled sync metadata' do
it 'leaves it alone when last synced within the plan cadence' do
create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 1.hour.ago
)
clear_enqueued_jobs
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
end
it 'queues a sync when last synced before the plan cadence' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 3.days.ago
)
clear_enqueued_jobs
expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
end
it 'marks the due document as syncing before queueing' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 3.days.ago
)
clear_enqueued_jobs
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
described_class.new.perform
expect(document.reload).to have_attributes(
sync_status: 'syncing',
last_sync_attempted_at: Time.current
)
end
end
it 'does not queue the same document again while the reserved sync is fresh' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
sync_status: :synced,
last_synced_at: 2.days.ago
)
clear_enqueued_jobs
expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
clear_enqueued_jobs
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
end
end
context 'when an available document was synced within the plan cadence' do
it 'leaves it alone' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
document.update!(sync_status: :synced, last_synced_at: 1.hour.ago, last_sync_attempted_at: 1.hour.ago)
clear_enqueued_jobs
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
end
end
context 'when an available document was last synced before the plan cadence' do
it 'queues a sync for that document' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
clear_enqueued_jobs
expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
end
end
context 'when more documents are due than the account cap allows' do
before do
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 2)
end
it 'queues backfilled and oldest-attempted documents first' do
newest_document = create(:captain_document, assistant: assistant, account: account, status: :available)
oldest_document = create(:captain_document, assistant: assistant, account: account, status: :available)
backfilled_document = create(:captain_document, assistant: assistant, account: account, status: :available)
newest_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
oldest_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
backfilled_document.update!(sync_status: :synced, last_synced_at: 4.days.ago, last_sync_attempted_at: nil)
clear_enqueued_jobs
expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(backfilled_document)
.and have_enqueued_job(Captain::Documents::PerformSyncJob).with(oldest_document)
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(newest_document)
end
end
context 'when an available document failed before the plan cadence' do
it 'queues a sync for that document' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
document.update!(sync_status: :failed, last_sync_attempted_at: 2.days.ago)
clear_enqueued_jobs
expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
end
end
context 'when a document is stuck in syncing past the scheduler stale timeout' do
it 'requeues a sync to recover the lock' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
document.update!(
sync_status: :syncing,
last_sync_attempted_at: (described_class::SYNC_STALE_TIMEOUT + 1.minute).ago
)
clear_enqueued_jobs
expect { described_class.new.perform }
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
end
end
context 'when a document has been queued longer than the worker lock timeout' do
it 'leaves it alone so queue lag is not treated as a dead worker' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
document.update!(
sync_status: :syncing,
last_sync_attempted_at: (Captain::Documents::PerformSyncJob::LOCK_TIMEOUT + 1.minute).ago
)
clear_enqueued_jobs
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
end
end
context 'when a document is currently syncing within the scheduler stale timeout' do
it 'leaves it alone so the holder can finish' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
clear_enqueued_jobs
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
end
end
context 'when the only eligible document is a PDF' do
it 'leaves it alone since PDFs are not syncable' do
pdf_document = build(:captain_document, assistant: assistant, account: account, status: :available)
pdf_document.pdf_file.attach(io: StringIO.new('PDF content'), filename: 'test.pdf',
content_type: 'application/pdf')
pdf_document.save!
clear_enqueued_jobs
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
end
end
context 'when an in-progress (still crawling) document is past the cadence' do
it 'leaves it alone since only available documents are eligible' do
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
document.update!(last_sync_attempted_at: 2.days.ago)
clear_enqueued_jobs
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
end
end
end
@@ -15,17 +15,22 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
end
it 'creates a new document when one does not exist' do
expect do
described_class.perform_now(assistant_id: assistant.id, payload: payload)
end.to change(assistant.documents, :count).by(1)
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, payload: payload)
end.to change(assistant.documents, :count).by(1)
document = assistant.documents.last
expect(document).to have_attributes(
content: payload[:markdown],
name: payload[:metadata]['title'],
external_link: 'https://www.firecrawl.dev',
status: 'available'
)
document = assistant.documents.last
expect(document).to have_attributes(
content: payload[:markdown],
name: payload[:metadata]['title'],
external_link: 'https://www.firecrawl.dev',
status: 'available',
sync_status: 'synced',
last_synced_at: Time.current,
last_sync_attempted_at: Time.current
)
end
end
it 'updates existing document when one exists' do
@@ -37,18 +42,23 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
name: 'old title',
status: :in_progress)
expect do
described_class.perform_now(assistant_id: assistant.id, payload: payload)
end.not_to change(assistant.documents, :count)
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, payload: payload)
end.not_to change(assistant.documents, :count)
existing_document.reload
# Payload URL ends with '/', but we persist the canonical URL without it.
expect(existing_document).to have_attributes(
external_link: 'https://www.firecrawl.dev',
content: payload[:markdown],
name: payload[:metadata]['title'],
status: 'available'
)
existing_document.reload
# Payload URL ends with '/', but we persist the canonical URL without it.
expect(existing_document).to have_attributes(
external_link: 'https://www.firecrawl.dev',
content: payload[:markdown],
name: payload[:metadata]['title'],
status: 'available',
sync_status: 'synced',
last_synced_at: Time.current,
last_sync_attempted_at: Time.current
)
end
end
context 'when an error occurs' do
@@ -14,20 +14,28 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
.and_return(crawler)
allow(crawler).to receive(:page_title).and_return(page_title)
allow(crawler).to receive(:body_text_content).and_return(content)
allow(crawler).to receive(:body_markdown).and_return(content)
allow(crawler).to receive(:success?).and_return(true)
end
context 'when the page is successfully crawled' do
it 'creates a new document if one does not exist' do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.to change(assistant.documents, :count).by(1)
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.to change(assistant.documents, :count).by(1)
document = assistant.documents.last
expect(document.external_link).to eq('https://example.com/page')
expect(document.name).to eq(page_title)
expect(document.content).to eq(content)
expect(document.status).to eq('available')
document = assistant.documents.last
expect(document.external_link).to eq('https://example.com/page')
expect(document.name).to eq(page_title)
expect(document.content).to eq(content)
expect(document).to have_attributes(
status: 'available',
sync_status: 'synced',
last_synced_at: Time.current,
last_sync_attempted_at: Time.current
)
end
end
it 'updates existing document if one exists' do
@@ -37,14 +45,21 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
name: 'Old Title',
content: 'Old content')
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.not_to change(assistant.documents, :count)
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.not_to change(assistant.documents, :count)
existing_document.reload
expect(existing_document.name).to eq(page_title)
expect(existing_document.content).to eq(content)
expect(existing_document.status).to eq('available')
existing_document.reload
expect(existing_document.name).to eq(page_title)
expect(existing_document.content).to eq(content)
expect(existing_document).to have_attributes(
status: 'available',
sync_status: 'synced',
last_synced_at: Time.current,
last_sync_attempted_at: Time.current
)
end
end
context 'when title or content exceed maximum length' do
@@ -53,7 +68,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
before do
allow(crawler).to receive(:page_title).and_return(long_title)
allow(crawler).to receive(:body_text_content).and_return(long_content)
allow(crawler).to receive(:body_markdown).and_return(long_content)
end
it 'truncates the title and content' do
@@ -78,10 +93,82 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
end
end
context 'when the page fetch fails' do
before do
allow(crawler).to receive(:success?).and_return(false)
allow(crawler).to receive(:status_code).and_return(500)
end
it 'raises an error without creating an available document' do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.to raise_error("Failed to parse data: #{page_link} Failed to fetch page: #{page_link}")
.and not_change(assistant.documents, :count)
end
it 'marks an existing document as available and failed' do
document = create(
:captain_document,
assistant: assistant,
account: assistant.account,
external_link: 'https://example.com/page',
status: :in_progress
)
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.to raise_error("Failed to parse data: #{page_link} Failed to fetch page: #{page_link}")
expect(document.reload).to have_attributes(
status: 'available',
sync_status: 'failed',
last_sync_error_code: 'fetch_failed',
last_sync_attempted_at: Time.current
)
end
end
context 'when the failure is permanent' do
before do
allow(crawler).to receive(:status_code).and_return(404)
end
it 'does not retry a discovered link that was never persisted' do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.not_to change(assistant.documents, :count)
end
it 'marks an existing document as available and failed without raising' do
document = create(
:captain_document,
assistant: assistant,
account: assistant.account,
external_link: 'https://example.com/page',
status: :in_progress
)
freeze_time do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.not_to raise_error
expect(document.reload).to have_attributes(
status: 'available',
sync_status: 'failed',
last_sync_error_code: 'not_found',
last_sync_attempted_at: Time.current
)
end
end
end
end
context 'when title and content are nil' do
before do
allow(crawler).to receive(:page_title).and_return(nil)
allow(crawler).to receive(:body_text_content).and_return(nil)
allow(crawler).to receive(:body_markdown).and_return(nil)
end
it 'creates document with empty strings and updates the status to available' do
+45
View File
@@ -222,6 +222,51 @@ RSpec.describe Account, type: :model do
end
end
describe 'captain document sync cadence' do
let(:account) { create(:account) }
it 'has no cadence on the hacker plan' do
account.update!(custom_attributes: { plan_name: 'hacker' })
expect(account.captain_document_sync_interval).to be_nil
end
it 'syncs weekly on the startups plan' do
account.update!(custom_attributes: { plan_name: 'startups' })
expect(account.captain_document_sync_interval).to eq(7.days)
end
it 'syncs daily on the business plan' do
account.update!(custom_attributes: { plan_name: 'business' })
expect(account.captain_document_sync_interval).to eq(1.day)
end
it 'syncs every six hours on the enterprise plan' do
account.update!(custom_attributes: { plan_name: 'enterprise' })
expect(account.captain_document_sync_interval).to eq(6.hours)
end
it 'has no cadence when plan is missing' do
account.update!(custom_attributes: {})
expect(account.captain_document_sync_interval).to be_nil
end
it 'has no cadence for unknown plans' do
account.update!(custom_attributes: { plan_name: 'mystery' })
expect(account.captain_document_sync_interval).to be_nil
end
it 'normalizes plan name casing' do
account.update!(custom_attributes: { plan_name: 'Business' })
expect(account.captain_document_sync_interval).to eq(1.day)
end
it 'syncs every six hours on self-hosted enterprise installs without a plan_name' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
account.update!(custom_attributes: {})
expect(account.captain_document_sync_interval).to eq(6.hours)
end
end
describe 'account deletion' do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
@@ -0,0 +1,103 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::TwilioSms do
let(:account) { create(:account) }
let(:twiml_app_sid) { 'AP1234567890abcdef' }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: twiml_app_sid))
end
describe 'factory' do
it 'has a valid :with_voice factory' do
channel = create(:channel_twilio_sms, :with_voice, account: account)
expect(channel).to be_valid
expect(channel.voice_enabled?).to be true
end
end
describe 'validations' do
it 'requires a phone number when voice is enabled' do
channel = build(:channel_twilio_sms, :with_voice, account: account, phone_number: nil)
channel.valid?
expect(channel.errors[:base]).to include('Voice calling requires a phone number and cannot be used with messaging service SID')
end
end
describe '#voice_enabled?' do
it 'returns true when voice_enabled is set' do
channel = create(:channel_twilio_sms, :with_voice, account: account)
expect(channel.voice_enabled?).to be true
end
it 'returns false by default' do
channel = create(:channel_twilio_sms, account: account)
expect(channel.voice_enabled?).to be false
end
end
describe '#voice_call_webhook_url' do
it 'returns the webhook URL based on phone number' do
channel = create(:channel_twilio_sms, :with_voice)
digits = channel.phone_number.delete_prefix('+')
expect(channel.voice_call_webhook_url).to include(digits)
end
end
describe '#voice_status_webhook_url' do
it 'returns the status webhook URL based on phone number' do
channel = create(:channel_twilio_sms, :with_voice)
digits = channel.phone_number.delete_prefix('+')
expect(channel.voice_status_webhook_url).to include(digits)
end
end
describe 'provisioning on create' do
it 'stores twiml_app_sid from the webhook setup service' do
stub_request(:get, %r{api\.twilio\.com/2010-04-01/Accounts/.*/IncomingPhoneNumbers\.json})
.to_return(status: 200, body: { incoming_phone_numbers: [{ capabilities: { 'voice' => true } }] }.to_json,
headers: { 'Content-Type' => 'application/json' })
channel = create(:channel_twilio_sms, :with_voice, twiml_app_sid: nil)
expect(channel.twiml_app_sid).to eq(twiml_app_sid)
end
end
describe 'teardown on disable' do
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:app_context) { instance_double(Twilio::REST::Api::V2010::AccountContext::ApplicationContext) }
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:numbers_list) { instance_double(Twilio::REST::Api::V2010::AccountContext::IncomingPhoneNumberList) }
before do
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
allow(twilio_client).to receive(:applications).with(channel.twiml_app_sid).and_return(app_context)
allow(app_context).to receive(:delete)
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(numbers_list)
allow(numbers_list).to receive(:list).with(phone_number: channel.phone_number).and_return([])
end
it 'deletes the TwiML app and clears twiml_app_sid' do
original_twiml_sid = channel.twiml_app_sid
channel.update!(voice_enabled: false)
expect(twilio_client).to have_received(:applications).with(original_twiml_sid)
expect(app_context).to have_received(:delete)
expect(channel.reload.twiml_app_sid).to be_nil
end
it 'preserves api_key_sid and api_key_secret' do
channel.update!(voice_enabled: false)
expect(channel.reload.api_key_sid).to be_present
expect(channel.reload.api_key_secret).to be_present
end
it 'does not fail if Twilio API errors' do
allow(app_context).to receive(:delete).and_raise(StandardError.new('Not found'))
expect { channel.update!(voice_enabled: false) }.not_to raise_error
expect(channel.reload.twiml_app_sid).to be_nil
end
end
end
@@ -1,79 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::Voice do
let(:twiml_app_sid) { 'AP1234567890abcdef' }
let(:channel) { create(:channel_voice) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: twiml_app_sid))
end
it 'has a valid factory' do
expect(channel).to be_valid
end
describe 'validations' do
it 'validates presence of provider_config' do
channel.provider_config = nil
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include("can't be blank")
end
it 'validates presence of account_sid in provider_config' do
channel.provider_config = { auth_token: 'token' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('account_sid is required for Twilio provider')
end
it 'validates presence of auth_token in provider_config' do
channel.provider_config = { account_sid: 'sid' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('auth_token is required for Twilio provider')
end
it 'validates presence of api_key_sid in provider_config' do
channel.provider_config = { account_sid: 'sid', auth_token: 'token' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('api_key_sid is required for Twilio provider')
end
it 'validates presence of api_key_secret in provider_config' do
channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider')
end
it 'validates presence of twiml_app_sid in provider_config' do
channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key', api_key_secret: 'secret' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('twiml_app_sid is required for Twilio provider')
end
it 'is valid with all required provider_config fields' do
channel.provider_config = {
account_sid: 'test_sid',
auth_token: 'test_token',
api_key_sid: 'test_key',
api_key_secret: 'test_secret',
twiml_app_sid: 'test_app_sid'
}
expect(channel).to be_valid
end
end
describe '#name' do
it 'returns Voice with phone number' do
expect(channel.name).to include('Voice')
expect(channel.name).to include(channel.phone_number)
end
end
describe 'provisioning on create' do
it 'stores twiml_app_sid in provider_config' do
ch = create(:channel_voice)
expect(ch.provider_config.with_indifferent_access[:twiml_app_sid]).to eq(twiml_app_sid)
end
end
end
@@ -0,0 +1,34 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Captain::AssistantPolicy, type: :policy do
subject(:assistant_policy) { described_class }
let(:account) { create(:account) }
let(:administrator) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
permissions :index?, :show?, :playground? do
context 'when administrator' do
it { expect(assistant_policy).to permit(administrator_context, assistant) }
end
context 'when agent' do
it { expect(assistant_policy).to permit(agent_context, assistant) }
end
end
permissions :tools?, :create?, :update?, :destroy?, :sync? do
context 'when administrator' do
it { expect(assistant_policy).to permit(administrator_context, assistant) }
end
context 'when agent' do
it { expect(assistant_policy).not_to permit(agent_context, assistant) }
end
end
end
@@ -29,7 +29,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
describe '#analyze' do
context 'when website content is available and LLM call is successful' do
before do
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:body_markdown).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
@@ -56,7 +56,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
context 'when website content fetch raises an error' do
before do
allow(mock_crawler).to receive(:body_text_content).and_raise(StandardError, 'Network error')
allow(mock_crawler).to receive(:body_markdown).and_raise(StandardError, 'Network error')
end
it 'returns error response' do
@@ -69,7 +69,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
context 'when website content is empty' do
before do
allow(mock_crawler).to receive(:body_text_content).and_return('')
allow(mock_crawler).to receive(:body_markdown).and_return('')
allow(mock_crawler).to receive(:page_title).and_return('')
allow(mock_crawler).to receive(:meta_description).and_return('')
end
@@ -84,7 +84,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
context 'when LLM call fails' do
before do
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:body_markdown).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
@@ -103,7 +103,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
let(:invalid_response) { instance_double(RubyLLM::Message, content: 'not valid json') }
before do
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:body_markdown).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
@@ -122,7 +122,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
let(:website_url) { 'example.com' }
before do
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome')
allow(mock_crawler).to receive(:body_markdown).and_return('Welcome')
allow(mock_crawler).to receive(:page_title).and_return('Example')
allow(mock_crawler).to receive(:meta_description).and_return('Description')
allow(mock_crawler).to receive(:favicon_url).and_return(nil)
@@ -33,7 +33,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
end
it 'raises an error' do
expect { described_class.new }.to raise_error(NoMethodError)
expect { described_class.new }.to raise_error('Missing API key')
end
end
@@ -36,6 +36,31 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
end
end
describe '#success?' do
context 'when the fetch succeeds' do
before do
stub_request(:get, base_url)
.to_return(status: 200, body: '<html><head><title>Example Page</title></head></html>')
end
it 'returns true' do
expect(service.success?).to be(true)
expect(service.status_code).to eq(200)
end
end
context 'when the fetch returns a non-success response' do
before do
stub_request(:get, base_url).to_return(status: 404, body: 'Not found')
end
it 'returns false and exposes the status code' do
expect(service.success?).to be(false)
expect(service.status_code).to eq(404)
end
end
end
describe '#page_links' do
context 'with HTML page' do
let(:html_content) do
@@ -95,7 +120,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
end
end
describe '#body_text_content' do
describe '#body_markdown' do
let(:html_content) do
<<~HTML
<html>
@@ -117,7 +142,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
end
it 'converts body content to markdown' do
expect(service.body_text_content).to eq("# Main Title\n\nConverted markdown")
expect(service.body_markdown).to eq("# Main Title\n\nConverted markdown")
expect(ReverseMarkdown).to have_received(:convert).with(
kind_of(Nokogiri::XML::Element),
unknown_tags: :bypass,
@@ -9,14 +9,16 @@ RSpec.describe Twilio::VoiceWebhookSetupService do
let(:api_key_secret) { 'api_key_secret_123' }
let(:phone_number) { '+15551230001' }
let(:frontend_url) { 'https://app.chatwoot.test' }
let(:account) { create(:account) }
let(:channel) do
build(:channel_voice, phone_number: phone_number, provider_config: {
account_sid: account_sid,
auth_token: auth_token,
api_key_sid: api_key_sid,
api_key_secret: api_key_secret
})
build(:channel_twilio_sms, :with_voice,
account: account,
phone_number: phone_number,
account_sid: account_sid,
auth_token: auth_token,
api_key_sid: api_key_sid,
api_key_secret: api_key_secret)
end
let(:twilio_base_url) { "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}" }
@@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Voice::InboundCallBuilder do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551239999') }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550001111' }
let(:to_number) { channel.phone_number }
@@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Voice::OutboundCallBuilder do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230000') }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230000') }
let(:inbox) { channel.inbox }
let(:user) { create(:user, account: account) }
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
@@ -2,7 +2,7 @@ require 'rails_helper'
describe Voice::Provider::Twilio::Adapter do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:adapter) { described_class.new(channel) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) }
@@ -19,7 +19,7 @@ describe Voice::Provider::Twilio::Adapter do
allow(calls_double).to receive(:create).and_return(call_instance)
allow(Twilio::REST::Client).to receive(:new)
.with(channel.provider_config_hash['account_sid'], channel.provider_config_hash['auth_token'])
.with(channel.account_sid, channel.auth_token)
.and_return(client_double)
result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42)
@@ -2,14 +2,15 @@ require 'rails_helper'
describe Voice::Provider::Twilio::ConferenceService do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:service) { described_class.new(conversation: conversation, twilio_client: twilio_client) }
let(:service) { described_class.new(conversation: conversation) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
end
describe '#ensure_conference_sid' do
@@ -3,7 +3,7 @@ require 'rails_helper'
describe Voice::Provider::Twilio::TokenService do
let(:account) { create(:account) }
let(:user) { create(:user, :administrator, account: account) }
let(:voice_channel) { create(:channel_voice, account: account) }
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:inbox) { voice_channel.inbox }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
@@ -27,7 +27,7 @@ RSpec.describe Voice::StatusUpdateService do
content_attributes: { data: { call_sid: call_sid, status: 'ringing' } }
)
end
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230002') }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550002222' }
let(:call_sid) { 'CATESTSTATUS123' }
-21
View File
@@ -1,21 +0,0 @@
# frozen_string_literal: true
FactoryBot.define do
factory :channel_voice, class: 'Channel::Voice' do
sequence(:phone_number) { |n| "+155512345#{n.to_s.rjust(2, '0')}" }
provider_config do
{
account_sid: "AC#{SecureRandom.hex(16)}",
auth_token: SecureRandom.hex(16),
api_key_sid: SecureRandom.hex(8),
api_key_secret: SecureRandom.hex(16),
twiml_app_sid: "AP#{SecureRandom.hex(16)}"
}
end
account
after(:create) do |channel_voice|
create(:inbox, channel: channel_voice, account: channel_voice.account)
end
end
end
+8
View File
@@ -17,5 +17,13 @@ FactoryBot.define do
trait :whatsapp do
medium { :whatsapp }
end
trait :with_voice do
with_phone_number
voice_enabled { true }
api_key_sid { "SK#{SecureRandom.hex(16)}" }
api_key_secret { SecureRandom.hex(16) }
twiml_app_sid { "AP#{SecureRandom.hex(16)}" }
end
end
end
@@ -0,0 +1,347 @@
require 'rails_helper'
describe Whatsapp::LiquidTemplateProcessorService do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, name: 'Agent Smith') }
let(:inbox) { create(:inbox, account: account, name: 'Support Inbox') }
let(:contact) { create(:contact, account: account, name: 'John Doe', email: 'john@example.com', phone_number: '+1234567890') }
let(:campaign) { create(:campaign, account: account, inbox: inbox, sender: agent, message: 'Test message') }
let(:service) { described_class.new(campaign: campaign, contact: contact) }
describe '#process_template_params' do
context 'when template_params is blank' do
it 'returns the original template_params' do
result = service.process_template_params(nil)
expect(result).to be_nil
end
end
context 'when processed_params is blank' do
let(:template_params) { { 'name' => 'test_template' } }
it 'returns the original template_params' do
result = service.process_template_params(template_params)
expect(result).to eq(template_params)
end
end
context 'with body parameters containing liquid variables' do
let(:template_params) do
{
'name' => 'test_template',
'namespace' => 'test_namespace',
'language' => 'en',
'processed_params' => {
'body' => {
'name' => '{{contact.name}}',
'email' => '{{contact.email}}',
'static_text' => 'Hello World'
}
}
}
end
it 'processes liquid variables in body parameters' do
result = service.process_template_params(template_params)
contact_drop_name = ContactDrop.new(contact).name
expect(result['processed_params']['body']['name']).to eq(contact_drop_name)
expect(result['processed_params']['body']['email']).to eq(contact.email)
expect(result['processed_params']['body']['static_text']).to eq('Hello World')
end
it 'does not modify the original template_params' do
original_name_value = template_params['processed_params']['body']['name']
service.process_template_params(template_params)
expect(template_params['processed_params']['body']['name']).to eq(original_name_value)
end
end
context 'with header parameters containing liquid variables' do
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'header' => {
'media_url' => 'https://example.com/{{contact.name}}.jpg',
'media_name' => '{{contact.name}}_document.pdf'
}
}
}
end
it 'processes liquid variables in header parameters' do
result = service.process_template_params(template_params)
contact_drop_name = ContactDrop.new(contact).name
expect(result['processed_params']['header']['media_url']).to eq("https://example.com/#{contact_drop_name}.jpg")
expect(result['processed_params']['header']['media_name']).to eq("#{contact_drop_name}_document.pdf")
end
end
context 'with button parameters containing liquid variables' do
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'buttons' => [
{ 'type' => 'url', 'parameter' => '{{contact.email}}' },
{ 'type' => 'copy_code', 'parameter' => 'CODE-{{contact.name}}' }
]
}
}
end
it 'processes liquid variables in button parameters' do
result = service.process_template_params(template_params)
contact_drop_name = ContactDrop.new(contact).name
expect(result['processed_params']['buttons'][0]['parameter']).to eq(contact.email)
expect(result['processed_params']['buttons'][1]['parameter']).to eq("CODE-#{contact_drop_name}")
end
end
context 'with footer parameters containing liquid variables' do
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'footer' => {
'text' => 'From {{agent.name}} at {{account.name}}'
}
}
}
end
it 'processes liquid variables in footer parameters' do
result = service.process_template_params(template_params)
agent_drop_name = UserDrop.new(agent).name
expect(result['processed_params']['footer']['text']).to eq("From #{agent_drop_name} at #{account.name}")
end
end
context 'with multiple liquid variables across different sections' do
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'body' => {
'greeting' => 'Hello {{contact.name}}',
'agent' => 'Your agent is {{agent.name}}'
},
'header' => {
'media_name' => '{{contact.name}}_file.pdf'
},
'buttons' => [
{ 'parameter' => '{{contact.email}}' }
],
'footer' => {
'text' => '{{inbox.name}}'
}
}
}
end
it 'processes all liquid variables correctly' do
result = service.process_template_params(template_params)
contact_drop_name = ContactDrop.new(contact).name
agent_drop_name = UserDrop.new(agent).name
expect(result['processed_params']['body']['greeting']).to eq("Hello #{contact_drop_name}")
expect(result['processed_params']['body']['agent']).to eq("Your agent is #{agent_drop_name}")
expect(result['processed_params']['header']['media_name']).to eq("#{contact_drop_name}_file.pdf")
expect(result['processed_params']['buttons'][0]['parameter']).to eq(contact.email)
expect(result['processed_params']['footer']['text']).to eq(inbox.name)
end
end
context 'with blank or nil values' do
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'body' => {
'name' => nil,
'email' => '',
'valid' => '{{contact.name}}'
}
}
}
end
it 'handles blank values gracefully' do
result = service.process_template_params(template_params)
contact_drop_name = ContactDrop.new(contact).name
expect(result['processed_params']['body']['name']).to be_nil
expect(result['processed_params']['body']['email']).to eq('')
expect(result['processed_params']['body']['valid']).to eq(contact_drop_name)
end
end
context 'when liquid variable resolves to blank' do
let(:contact) { create(:contact, account: account, name: 'John', email: nil, phone_number: '+1234567890') }
it 'returns nil for enhanced params with blank rendered values' do
template_params = {
'name' => 'test_template',
'processed_params' => {
'body' => {
'email' => '{{contact.email}}'
}
}
}
result = service.process_template_params(template_params)
expect(result).to be_nil
end
it 'returns nil for legacy hash params with blank rendered values' do
template_params = {
'name' => 'test_template',
'processed_params' => {
'1' => '{{contact.email}}'
}
}
result = service.process_template_params(template_params)
expect(result).to be_nil
end
it 'returns nil for legacy array params with blank rendered values' do
template_params = {
'name' => 'test_template',
'processed_params' => ['{{contact.email}}']
}
result = service.process_template_params(template_params)
expect(result).to be_nil
end
it 'returns nil for button params with blank rendered values' do
template_params = {
'name' => 'test_template',
'processed_params' => {
'buttons' => [
{ 'type' => 'url', 'parameter' => '{{contact.email}}' }
]
}
}
result = service.process_template_params(template_params)
expect(result).to be_nil
end
it 'returns processed params when all variables resolve to non-blank values' do
template_params = {
'name' => 'test_template',
'processed_params' => {
'body' => {
'name' => '{{contact.name}}'
}
}
}
result = service.process_template_params(template_params)
expect(result).not_to be_nil
expect(result['processed_params']['body']['name']).to eq(ContactDrop.new(contact).name)
end
end
context 'with custom attributes' do
let(:contact) do
create(:contact, account: account, name: 'John Doe',
custom_attributes: { 'company' => 'Acme Inc', 'plan' => 'Premium' })
end
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'body' => {
'company' => '{{contact.custom_attribute.company}}',
'plan' => '{{contact.custom_attribute.plan}}'
}
}
}
end
it 'processes custom attribute liquid variables' do
result = service.process_template_params(template_params)
expect(result['processed_params']['body']['company']).to eq('Acme Inc')
expect(result['processed_params']['body']['plan']).to eq('Premium')
end
end
context 'with invalid liquid syntax' do
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'body' => {
'invalid' => '{{contact.name missing braces'
}
}
}
end
it 'returns original value when liquid parsing fails' do
result = service.process_template_params(template_params)
expect(result['processed_params']['body']['invalid']).to eq('{{contact.name missing braces')
end
end
context 'with legacy flat hash processed_params' do
let(:template_params) do
{
'name' => 'legacy_template',
'processed_params' => {
'1' => '{{contact.name}}',
'2' => '{{contact.email}}',
'3' => 'Hello World'
}
}
end
it 'processes liquid variables in legacy hash values' do
result = service.process_template_params(template_params)
contact_drop_name = ContactDrop.new(contact).name
expect(result['processed_params']['1']).to eq(contact_drop_name)
expect(result['processed_params']['2']).to eq(contact.email)
expect(result['processed_params']['3']).to eq('Hello World')
end
it 'treats component-named string values as legacy params' do
params_with_component_named_key = {
'name' => 'legacy_template',
'processed_params' => { 'body' => '{{contact.name}}' }
}
result = service.process_template_params(params_with_component_named_key)
contact_drop_name = ContactDrop.new(contact).name
expect(result['processed_params']['body']).to eq(contact_drop_name)
end
end
context 'with legacy array processed_params' do
let(:template_params) do
{
'name' => 'legacy_template',
'processed_params' => ['{{contact.name}}', '{{contact.email}}', 'Hello World']
}
end
it 'processes liquid variables in legacy array values' do
result = service.process_template_params(template_params)
contact_drop_name = ContactDrop.new(contact).name
expect(result['processed_params']).to eq([contact_drop_name, contact.email, 'Hello World'])
end
end
end
end
@@ -139,6 +139,73 @@ describe Whatsapp::OneoffCampaignService do
described_class.new(campaign: campaign).perform
end
it 'processes liquid variables in template parameters' do
contact = create(:contact, :with_phone_number, account: account, name: 'Jane Smith', email: 'jane@example.com')
contact.update_labels([label1.title])
campaign_with_liquid = create(:campaign, inbox: whatsapp_inbox, account: account,
audience: [{ type: 'Label', id: label1.id }],
template_params: {
'name' => 'ticket_status_updated',
'namespace' => '23423423_2342423_324234234_2343224',
'category' => 'UTILITY',
'language' => 'en',
'processed_params' => {
'body' => {
'name' => '{{contact.name}}',
'ticket_id' => '{{contact.email}}'
}
}
})
contact_drop_name = ContactDrop.new(contact).name
expect(whatsapp_channel).to receive(:send_template).with(
contact.phone_number,
hash_including(
name: 'ticket_status_updated',
namespace: '23423423_2342423_324234234_2343224',
lang_code: 'en',
parameters: array_including(
hash_including(
type: 'body',
parameters: array_including(
hash_including(type: 'text', parameter_name: 'name', text: contact_drop_name),
hash_including(type: 'text', parameter_name: 'ticket_id', text: contact.email)
)
)
)
),
nil
)
described_class.new(campaign: campaign_with_liquid).perform
end
it 'skips contacts when liquid variables resolve to blank values' do
contact = create(:contact, :with_phone_number, account: account, name: 'Jane', email: nil)
contact.update_labels([label1.title])
campaign_with_blank_liquid = create(:campaign, inbox: whatsapp_inbox, account: account,
audience: [{ type: 'Label', id: label1.id }],
template_params: {
'name' => 'test_template',
'namespace' => 'test_namespace',
'language' => 'en',
'processed_params' => {
'body' => {
'email' => '{{contact.email}}'
}
}
})
expect(whatsapp_channel).not_to receive(:send_template)
expect(Rails.logger).to receive(:info).with("Skipping contact #{contact.name} - liquid variables resolved to blank values")
allow(Rails.logger).to receive(:info)
described_class.new(campaign: campaign_with_blank_liquid).perform
end
end
context 'when template_params is missing' do