Merge branch 'develop' into feat/v5-ui-redesign

This commit is contained in:
Sivin Varghese
2025-12-08 21:27:44 +05:30
committed by GitHub
103 changed files with 2968 additions and 1743 deletions
+1 -1
View File
@@ -1 +1 @@
20.5.1
23.7.0
+8 -1
View File
@@ -7,6 +7,7 @@ plugins:
require:
- ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb
- ./rubocop/attachment_download.rb
- ./rubocop/one_class_per_file.rb
Layout/LineLength:
@@ -41,6 +42,12 @@ Style/SymbolArray:
Style/OpenStructUse:
Enabled: false
Chatwoot/AttachmentDownload:
Enabled: true
Exclude:
- 'spec/**/*'
- 'test/**/*'
Style/OptionalBooleanParameter:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
@@ -88,7 +95,7 @@ Metrics/ModuleLength:
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
- 'enterprise/app/helpers/captain/tool_execution_helper.rb'
- enterprise/app/helpers/captain/chat_response_helper.rb
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
+3 -3
View File
@@ -191,10 +191,10 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.4.3'
gem 'ai-agents', '>= 0.7.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.9.1'
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
# OpenTelemetry for LLM observability
@@ -215,7 +215,7 @@ group :production do
end
group :development do
gem 'annotate'
gem 'annotaterb'
gem 'bullet'
gem 'letter_opener'
gem 'scss_lint', require: false
+9 -10
View File
@@ -126,11 +126,11 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.4.3)
ruby_llm (~> 1.3)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
ast (2.4.3)
attr_extras (7.1.0)
audited (5.4.1)
@@ -819,7 +819,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.9.1)
ruby_llm (1.8.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -827,7 +827,6 @@ GEM
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1.0)
ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2)
ruby_llm-schema (0.2.5)
ruby_parser (3.20.0)
@@ -1018,8 +1017,8 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.4.3)
annotate
ai-agents (>= 0.7.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
@@ -1120,7 +1119,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (>= 1.9.1)
ruby_llm (>= 1.8.2)
ruby_llm-schema
scout_apm
scss_lint
@@ -101,6 +101,6 @@ class Messages::Messenger::MessageBuilder
private
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type].include? attachment_type.to_sym
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
end
end
@@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => {
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout');
expect(accountAPI).toHaveProperty('toggleDeletion');
expect(accountAPI).toHaveProperty('createTopupCheckout');
expect(accountAPI).toHaveProperty('getLimits');
});
describe('API calls', () => {
@@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => {
{ action_type: 'undelete' }
);
});
it('#createTopupCheckout with credits', () => {
accountAPI.createTopupCheckout(1000);
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/topup_checkout',
{ credits: 1000 }
);
});
it('#createTopupCheckout with different credit amounts', () => {
const creditAmounts = [1000, 2500, 6000, 12000];
creditAmounts.forEach(credits => {
accountAPI.createTopupCheckout(credits);
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/topup_checkout',
{ credits }
);
});
});
it('#getLimits', () => {
accountAPI.getLimits();
expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
});
});
});
@@ -19,7 +19,6 @@ const props = defineProps({
},
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false },
@@ -102,7 +101,6 @@ watch(
:disabled="disabled"
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
@@ -139,19 +137,6 @@ watch(
.editor-wrapper {
::v-deep {
.ProseMirror-menubar-wrapper {
@apply gap-2 !important;
.ProseMirror-menubar {
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
.ProseMirror-menuitem {
@apply h-5 !important;
}
.ProseMirror-icon {
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
}
}
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
@@ -172,7 +172,7 @@ const previewArticle = () => {
@apply mr-0;
.ProseMirror-icon {
@apply p-0 mt-1 !mr-0;
@apply p-0 mt-0 !mr-0;
svg {
width: 20px !important;
@@ -26,13 +26,11 @@ import { useAlert } from 'dashboard/composables';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
messageSchema,
buildMessageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
@@ -53,6 +51,9 @@ import {
removeSignature as removeSignatureHelper,
scrollCursorIntoView,
setURLWithQueryAndSize,
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -75,7 +76,6 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true },
enableCaptainTools: { type: Boolean, default: false },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
// allowSignature is a kill switch, ensuring no signature methods
// are triggered except when this flag is true
@@ -103,22 +103,34 @@ const { t } = useI18n();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const createState = (
content,
placeholder,
plugins = [],
methods = {},
enabledMenuOptions = []
) => {
const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType;
const formatting = getFormattingForEditor(formatType);
return buildMessageSchema(formatting.marks, formatting.nodes);
});
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
: props.channelType || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
return formatting.menu;
});
const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value;
return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
doc: new MessageMarkdownTransformer(schema).parse(content),
plugins: buildEditor({
schema: messageSchema,
schema,
placeholder,
methods,
plugins,
enabledMenuOptions,
enabledMenuOptions: editorMenuOptions.value,
}),
});
};
@@ -153,6 +165,8 @@ const range = ref(null);
const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null);
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref
@@ -174,12 +188,6 @@ const shouldShowCannedResponses = computed(() => {
);
});
const editorMenuOptions = computed(() => {
return props.enabledMenuOptions.length
? props.enabledMenuOptions
: MESSAGE_EDITOR_MENU_OPTIONS;
});
function createSuggestionPlugin({
trigger,
minChars = 0,
@@ -400,6 +408,38 @@ function setToolbarPosition() {
};
}
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
const rect = wrapper.getBoundingClientRect();
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
// Calculate coords and final position
const coords = getSelectionCoords(editorView, selection, rect);
const { left, top, width } = calculateMenuPosition(coords, rect, isRtl);
wrapper.style.setProperty('--selection-left', `${left}px`);
wrapper.style.setProperty(
'--selection-right',
`${rect.width - left - width}px`
);
wrapper.style.setProperty('--selection-top', `${top}px`);
}
function checkSelection(editorState) {
showSelectionMenu.value = false;
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
const wrapper = editorRoot.value;
if (!wrapper) return;
wrapper.classList.toggle('has-selection', hasSelection);
if (hasSelection) setMenubarPosition(editorState);
}
function setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) {
return;
@@ -529,7 +569,9 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) {
function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
let node = new MessageMarkdownTransformer(messageSchema).parse(content);
// Use the editor's current schema to ensure compatibility with buildMessageSchema
const currentSchema = editorView.state.schema;
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
insertNodeIntoEditor(node, from, undefined);
}
@@ -596,6 +638,7 @@ function createEditorView() {
if (tx.docChanged) {
emitOnChange();
}
checkSelection(state);
},
handleDOMEvents: {
keyup: () => {
@@ -761,15 +804,33 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.ProseMirror-menubar-wrapper {
@apply flex flex-col;
@apply flex flex-col gap-3;
.ProseMirror-menubar {
min-height: 1.25rem !important;
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
@apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
.ProseMirror-menu-active {
@apply bg-n-slate-5 dark:bg-n-solid-3;
@apply bg-n-slate-5 dark:bg-n-solid-3 !important;
}
.ProseMirror-menuitem {
@apply mr-0 size-4 flex items-center justify-center;
.ProseMirror-icon {
@apply size-4 flex items-center justify-center flex-shrink-0;
svg {
@apply size-full;
}
}
}
}
.ProseMirror-menubar:not(:has(*)) {
max-height: none !important;
min-height: 0 !important;
padding: 0 !important;
}
> .ProseMirror {
@@ -860,4 +921,53 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.editor-warning__message {
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
}
// Float editor menu
.popover-prosemirror-menu {
position: relative;
.ProseMirror p:last-child {
margin-bottom: 10px !important;
}
.ProseMirror-menubar {
display: none; // Hide by default
}
&.has-selection {
// Hide menu completely when it has no items
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menubar {
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
width: fit-content !important;
position: absolute !important;
// Default/LTR: position from left
top: var(--selection-top);
left: var(--selection-left);
// RTL: position from right instead
[dir='rtl'] & {
left: auto;
right: var(--selection-right);
}
.ProseMirror-menuitem {
@apply mr-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0;
}
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
}
}
}
}
</style>
@@ -73,10 +73,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
showEditorToggle: {
type: Boolean,
default: false,
},
isOnPrivateNote: {
type: Boolean,
default: false,
@@ -126,7 +122,6 @@ const props = defineProps({
const emit = defineEmits([
'replaceText',
'toggleInsertArticle',
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
@@ -332,26 +327,12 @@ onMounted(() => {
sm
@click="toggleAudioRecorder"
/>
<div
v-if="showEditorToggle"
class="h-3 border-r border-n-strong flex-shrink-0 rounded-full"
/>
<NextButton
v-if="showEditorToggle"
v-tooltip.top-end="t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="i-ph-quotes"
slate
ghost
sm
@click="$emit('toggleEditor')"
/>
<div
v-if="showAudioPlayStopButton"
class="h-3 border-r border-n-strong flex-shrink-0 rounded-full"
/>
<NextButton
v-if="showAudioPlayStopButton"
v-tooltip.top-end="t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
:icon="audioRecorderPlayStopIcon"
slate
ghost
@@ -7,9 +7,7 @@ import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
@@ -45,8 +43,6 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
removeSignature,
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -61,7 +57,6 @@ export default {
ArticleSearchPopover,
AttachmentPreview,
AudioRecorder,
CannedResponse,
ReplyBoxBanner,
EmojiInput,
MessageSignatureMissingAlert,
@@ -69,7 +64,6 @@ export default {
ReplyEmailHead,
ReplyToMessage,
ReplyTopPanel,
ResizableTextArea,
ContentTemplates,
WhatsappTemplates,
WootMessageEditor,
@@ -86,7 +80,6 @@ export default {
setup() {
const {
uiSettings,
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
@@ -97,7 +90,6 @@ export default {
return {
uiSettings,
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
@@ -115,10 +107,7 @@ export default {
isRecordingAudio: false,
recordingAudioState: '',
recordingAudioDurationText: '',
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
toEmails: '',
@@ -159,20 +148,6 @@ export default {
!this.is360DialogWhatsAppChannel
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
if (this.isAPIInbox) {
const {
display_rich_content_editor: displayRichContentEditor = false,
} = this.uiSettings;
return displayRichContentEditor;
}
return false;
},
showWhatsappTemplates() {
// We support templates for API channels if someone updates templates manually via API
// That's why we don't explicitly check for channel type here
@@ -300,9 +275,6 @@ export default {
hasAttachments() {
return this.attachedFiles.length;
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload;
},
@@ -342,21 +314,11 @@ export default {
return !this.isPrivate && this.sendWithSignature;
},
isSignatureAvailable() {
return !!this.signatureToApply;
return !!this.messageSignature;
},
sendWithSignature() {
return this.fetchSignatureFlagFromUISettings(this.channelType);
},
editorMessageKey() {
const { editor_message_key: isEnabled } = this.uiSettings;
return isEnabled;
},
commandPlusEnterToSendEnabled() {
return this.editorMessageKey === 'cmd_enter';
},
enterToSendEnabled() {
return this.editorMessageKey === 'enter';
},
conversationId() {
return this.currentChat.id;
},
@@ -383,12 +345,6 @@ export default {
});
return variables;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox;
const { slug = '' } = portal;
@@ -481,25 +437,7 @@ export default {
this.resetRecorderAndClearAttachments();
}
},
message(updatedMessage) {
// Check if the message starts with a slash.
const bodyWithoutSignature = removeSignature(
updatedMessage,
this.signatureToApply
);
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Determine if the user is potentially typing a slash command.
// This is true if the message starts with a slash and the rich content editor is not active.
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
this.showMentions = this.hasSlashCommand;
// If a slash command is active, extract the command text after the slash.
// If not, reset the mentionSearchKey.
this.mentionSearchKey = this.hasSlashCommand
? bodyWithoutSignature.substring(1)
: '';
message() {
// Autosave the current message draft.
this.doAutoSaveDraft();
},
@@ -512,7 +450,7 @@ export default {
mounted() {
this.getFromDraft();
// Don't use the keyboard listener mixin here as the events here are supposed to be
// working even if input/textarea is focussed.
// working even if the editor is focussed.
document.addEventListener('paste', this.onPaste);
document.addEventListener('keydown', this.handleKeyEvents);
this.setCCAndToEmailsFromLastChat();
@@ -549,45 +487,17 @@ export default {
methods: {
handleInsert(article) {
const { url, title } = article;
if (this.isRichEditorEnabled) {
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
toggleRichContentEditor() {
this.updateUISettings({
display_rich_content_editor: !this.showRichContentEditor,
});
const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
if (!this.showRichContentEditor && this.messageSignature) {
// remove the old signature -> extract text from markdown -> attach new signature
let message = removeSignature(this.message, this.messageSignature);
message = extractTextFromMarkdown(message);
message = appendSignature(message, plainTextSignature);
this.message = message;
} else {
this.message = replaceSignature(
this.message,
plainTextSignature,
this.messageSignature
);
}
},
toggleQuotedReply() {
if (!this.isAnEmailChannel) {
return;
@@ -655,8 +565,8 @@ export default {
}
return this.sendWithSignature
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
? appendSignature(message, this.messageSignature)
: removeSignature(message, this.messageSignature);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
@@ -672,7 +582,6 @@ export default {
Escape: {
action: () => {
this.hideEmojiPicker();
this.hideMentions();
},
allowOnFocusedInput: true,
},
@@ -715,9 +624,6 @@ export default {
},
onPaste(e) {
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput.$el.blur();
}
if (!data.length || !data[0]) {
return;
}
@@ -851,7 +757,7 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
message = appendSignature(message, this.signatureToApply);
message = appendSignature(message, this.messageSignature);
}
const updatedMessage = replaceVariablesInMessage({
@@ -875,40 +781,22 @@ export default {
});
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) {
if (this.showRichContentEditor) {
this.updateEditorSelectionWith = content;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
this.updateEditorSelectionWith = content;
this.onFocus();
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
this.message = appendSignature(this.message, this.signatureToApply);
this.message = appendSignature(this.message, this.messageSignature);
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -926,19 +814,15 @@ export default {
},
toggleAudioRecorder() {
this.isRecordingAudio = !this.isRecordingAudio;
this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) {
this.resetAudioRecorderInput();
}
},
toggleAudioRecorderPlayPause() {
if (!this.isRecordingAudio) {
return;
}
if (!this.isRecorderAudioStopped) {
this.isRecorderAudioStopped = true;
if (!this.$refs.audioRecorderInput) return;
if (!this.recordingAudioState) {
this.$refs.audioRecorderInput.stopRecording();
} else if (this.isRecorderAudioStopped) {
} else {
this.$refs.audioRecorderInput.playPause();
}
},
@@ -947,9 +831,6 @@ export default {
this.toggleEmojiPicker();
}
},
hideMentions() {
this.showMentions = false;
},
onTypingOn() {
this.toggleTyping('on');
},
@@ -1196,13 +1077,6 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
@@ -1226,33 +1100,17 @@ export default {
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<ResizableTextArea
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
allow-signature
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<WootMessageEditor
v-else
v-model="message"
:editor-id="editorStateId"
class="input"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="signatureToApply"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
@typing-off="onTypingOff"
@@ -1302,7 +1160,6 @@ export default {
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
@@ -1315,7 +1172,6 @@ export default {
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
@@ -1370,10 +1226,6 @@ export default {
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
}
.emoji-dialog {
@@ -1393,9 +1245,4 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style>
+140 -27
View File
@@ -1,23 +1,143 @@
export const MESSAGE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'bulletList',
'orderedList',
'code',
];
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'imageUpload',
];
// Formatting rules for different contexts (channels and special contexts)
// marks: inline formatting (strong, em, code, link, strike)
// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
export const FORMATTING = {
// Channel formatting
'Channel::Email': {
marks: ['strong', 'em', 'code', 'link'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'strong',
'em',
'code',
'link',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::WebWidget': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::Api': {
marks: [],
nodes: [],
menu: [],
},
'Channel::FacebookPage': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'strong',
'em',
'code',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::TwitterProfile': {
marks: [],
nodes: [],
menu: [],
},
'Channel::TwilioSms': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Sms': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Whatsapp': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'strong',
'em',
'code',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::Line': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['codeBlock'],
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
},
'Channel::Telegram': {
marks: ['strong', 'em', 'link', 'code'],
nodes: [],
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
},
'Channel::Instagram': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList'],
menu: [
'strong',
'em',
'code',
'bulletList',
'orderedList',
'strike',
'undo',
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
// Special contexts (not actual channels)
'Context::Default': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Context::MessageSignature': {
marks: ['strong', 'em', 'link'],
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
},
'Context::InboxSettings': {
marks: ['strong', 'em', 'link'],
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo'],
},
};
// Editor menu options for Full Editor
export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
@@ -33,14 +153,7 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code',
];
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
];
// Editor image resize options for Message Editor
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
@@ -5,6 +5,7 @@ import {
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
import { FORMATTING } from 'dashboard/constants/editor';
/**
* The delimiter used to separate the signature from the rest of the body.
@@ -314,7 +315,7 @@ const createNode = (editorView, nodeType, content) => {
return mentionNode;
}
case 'cannedResponse':
return new MessageMarkdownTransformer(messageSchema).parse(content);
return new MessageMarkdownTransformer(state.schema).parse(content);
case 'variable':
return state.schema.text(`{{${content}}}`);
case 'emoji':
@@ -389,3 +390,85 @@ export const getContentNode = (
? creator(editorView, content, from, to, variables)
: { node: null, from, to };
};
/**
* Get the formatting configuration for a specific channel type.
* Returns the appropriate marks, nodes, and menu items for the editor.
*
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
*/
export function getFormattingForEditor(channelType) {
return FORMATTING[channelType] || FORMATTING['Context::Default'];
}
/**
* Menu Positioning Helpers
* Handles floating menu bar positioning for text selection in the editor.
*/
const MENU_CONFIG = { H: 46, W: 300, GAP: 10 };
/**
* Calculate selection coordinates with bias to handle line-wraps correctly.
* @param {EditorView} editorView - ProseMirror editor view
* @param {Selection} selection - Current text selection
* @param {DOMRect} rect - Container bounding rect
* @returns {{start: Object, end: Object, selTop: number, onTop: boolean}}
*/
export function getSelectionCoords(editorView, selection, rect) {
const start = editorView.coordsAtPos(selection.from, 1);
const end = editorView.coordsAtPos(selection.to, -1);
const selTop = Math.min(start.top, end.top);
const spaceAbove = selTop - rect.top;
const onTop =
spaceAbove > MENU_CONFIG.H + MENU_CONFIG.GAP || end.bottom > rect.bottom;
return { start, end, selTop, onTop };
}
/**
* Calculate anchor position based on selection visibility and RTL direction.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {number} Anchor x-position for menu
*/
export function getMenuAnchor(coords, rect, isRtl) {
const { start, end, onTop } = coords;
if (!onTop) return end.left;
// If start of selection is visible, align to text. Else stick to container edge.
if (start.top >= rect.top) return isRtl ? start.right : start.left;
return isRtl ? rect.right - MENU_CONFIG.GAP : rect.left + MENU_CONFIG.GAP;
}
/**
* Calculate final menu position (left, top) within container bounds.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {{left: number, top: number, width: number}}
*/
export function calculateMenuPosition(coords, rect, isRtl) {
const { start, end, selTop, onTop } = coords;
const anchor = getMenuAnchor(coords, rect, isRtl);
// Calculate Left: shift by width if RTL, then make relative to container
const rawLeft = (isRtl ? anchor - MENU_CONFIG.W : anchor) - rect.left;
// Ensure menu stays within container bounds
const left = Math.min(Math.max(0, rawLeft), rect.width - MENU_CONFIG.W);
// Calculate Top: align to selection or bottom of selection
const top = onTop
? Math.max(-26, selTop - rect.top - MENU_CONFIG.H - MENU_CONFIG.GAP)
: Math.max(start.bottom, end.bottom) - rect.top + MENU_CONFIG.GAP;
return { left, top, width: MENU_CONFIG.W };
}
/* End Menu Positioning Helpers */
@@ -1,15 +1,11 @@
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
import { getContentNode } from '../editorHelper';
import {
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
vi.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: vi.fn(),
messageSchema: {},
}));
vi.mock('@chatwoot/utils', () => ({
@@ -62,12 +58,18 @@ describe('getContentNode', () => {
const to = 10;
const updatedMessage = 'Hello John';
replaceVariablesInMessage.mockReturnValue(updatedMessage);
MessageMarkdownTransformer.mockImplementation(() => ({
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
}));
// Mock the node that will be returned by parse
const mockNode = { textContent: updatedMessage };
const { node } = getContentNode(
replaceVariablesInMessage.mockReturnValue(updatedMessage);
// Mock MessageMarkdownTransformer instance with parse method
const mockTransformer = {
parse: vi.fn().mockReturnValue(mockNode),
};
MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
const result = getContentNode(
editorView,
'cannedResponse',
content,
@@ -79,8 +81,15 @@ describe('getContentNode', () => {
message: content,
variables,
});
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
expect(node.textContent).toBe(updatedMessage);
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
editorView.state.schema
);
expect(mockTransformer.parse).toHaveBeenCalledWith(updatedMessage);
expect(result.node).toBe(mockNode);
expect(result.node.textContent).toBe(updatedMessage);
// When textContent matches updatedMessage, from should remain unchanged
expect(result.from).toBe(from);
expect(result.to).toBe(to);
});
});
@@ -9,7 +9,12 @@ import {
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
getFormattingForEditor,
getSelectionCoords,
getMenuAnchor,
calculateMenuPosition,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema';
import { Schema } from 'prosemirror-model';
@@ -258,15 +263,11 @@ describe('insertAtCursor', () => {
expect(result).toBeUndefined();
});
it('should unwrap doc nodes that are wrapped in a paragraph', () => {
const docNode = schema.node('doc', null, [
schema.node('paragraph', null, [schema.text('Hello')]),
]);
it('should insert text node at cursor position', () => {
const editorState = createEditorState();
const editorView = new EditorView(document.body, { state: editorState });
insertAtCursor(editorView, docNode, 0);
insertAtCursor(editorView, schema.text('Hello'), 0);
// Check if node was unwrapped and inserted correctly
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
@@ -626,3 +627,178 @@ describe('getContentNode', () => {
});
});
});
describe('getFormattingForEditor', () => {
describe('channel-specific formatting', () => {
it('returns full formatting for Email channel', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toEqual(FORMATTING['Channel::Email']);
});
it('returns full formatting for WebWidget channel', () => {
const result = getFormattingForEditor('Channel::WebWidget');
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
});
it('returns limited formatting for WhatsApp channel', () => {
const result = getFormattingForEditor('Channel::Whatsapp');
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
});
it('returns no formatting for API channel', () => {
const result = getFormattingForEditor('Channel::Api');
expect(result).toEqual(FORMATTING['Channel::Api']);
});
it('returns limited formatting for FacebookPage channel', () => {
const result = getFormattingForEditor('Channel::FacebookPage');
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
});
it('returns no formatting for TwitterProfile channel', () => {
const result = getFormattingForEditor('Channel::TwitterProfile');
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
});
it('returns no formatting for SMS channel', () => {
const result = getFormattingForEditor('Channel::Sms');
expect(result).toEqual(FORMATTING['Channel::Sms']);
});
it('returns limited formatting for Telegram channel', () => {
const result = getFormattingForEditor('Channel::Telegram');
expect(result).toEqual(FORMATTING['Channel::Telegram']);
});
it('returns formatting for Instagram channel', () => {
const result = getFormattingForEditor('Channel::Instagram');
expect(result).toEqual(FORMATTING['Channel::Instagram']);
});
});
describe('context-specific formatting', () => {
it('returns default formatting for Context::Default', () => {
const result = getFormattingForEditor('Context::Default');
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns signature formatting for Context::MessageSignature', () => {
const result = getFormattingForEditor('Context::MessageSignature');
expect(result).toEqual(FORMATTING['Context::MessageSignature']);
});
it('returns widget builder formatting for Context::InboxSettings', () => {
const result = getFormattingForEditor('Context::InboxSettings');
expect(result).toEqual(FORMATTING['Context::InboxSettings']);
});
});
describe('fallback behavior', () => {
it('returns default formatting for unknown channel type', () => {
const result = getFormattingForEditor('Channel::Unknown');
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for null channel type', () => {
const result = getFormattingForEditor(null);
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for undefined channel type', () => {
const result = getFormattingForEditor(undefined);
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for empty string', () => {
const result = getFormattingForEditor('');
expect(result).toEqual(FORMATTING['Context::Default']);
});
});
describe('return value structure', () => {
it('always returns an object with marks, nodes, and menu properties', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toHaveProperty('marks');
expect(result).toHaveProperty('nodes');
expect(result).toHaveProperty('menu');
expect(Array.isArray(result.marks)).toBe(true);
expect(Array.isArray(result.nodes)).toBe(true);
expect(Array.isArray(result.menu)).toBe(true);
});
});
});
describe('Menu positioning helpers', () => {
const mockEditorView = {
coordsAtPos: vi.fn((pos, bias) => {
// Return different coords based on position
if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 };
return { top: 100, bottom: 120, left: 150, right: 200 };
}),
};
const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 };
describe('getSelectionCoords', () => {
it('returns selection coordinates with onTop flag', () => {
const selection = { from: 0, to: 10 };
const result = getSelectionCoords(mockEditorView, selection, wrapperRect);
expect(result).toHaveProperty('start');
expect(result).toHaveProperty('end');
expect(result).toHaveProperty('selTop');
expect(result).toHaveProperty('onTop');
});
});
describe('getMenuAnchor', () => {
it('returns end.left when menu is below selection', () => {
const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false };
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150);
});
it('returns start.left for LTR when menu is above and visible', () => {
const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true };
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50);
});
it('returns start.right for RTL when menu is above and visible', () => {
const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true };
expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100);
});
});
describe('calculateMenuPosition', () => {
it('returns bounded left and top positions', () => {
const coords = {
start: { top: 100, bottom: 120, left: 50 },
end: { top: 100, bottom: 120, left: 150 },
selTop: 100,
onTop: false,
};
const result = calculateMenuPosition(coords, wrapperRect, false);
expect(result).toHaveProperty('left');
expect(result).toHaveProperty('top');
expect(result).toHaveProperty('width', 300);
expect(result.left).toBeGreaterThanOrEqual(0);
});
});
});
@@ -196,7 +196,6 @@
"INSERT_READ_MORE": "Read more",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
"TIP_AUDIORECORDER_ICON": "Record audio",
@@ -422,7 +422,15 @@
"PURCHASE": "Purchase Credits",
"LOADING": "Loading options...",
"FETCH_ERROR": "Failed to load credit options. Please try again.",
"PURCHASE_ERROR": "Failed to process purchase. Please try again."
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
"CONFIRM": {
"TITLE": "Confirm Purchase",
"DESCRIPTION": "You are about to purchase {credits} credits for {amount}.",
"INSTANT_DEDUCTION_NOTE": "Your saved card will be charged immediately upon confirmation.",
"GO_BACK": "Go Back",
"CONFIRM_PURCHASE": "Confirm Purchase"
}
}
},
"SECURITY_SETTINGS": {
@@ -5,7 +5,9 @@ import {
useFunctionGetter,
useStore,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
@@ -53,12 +55,22 @@ const isShopifyFeatureEnabled = computed(
() => shopifyIntegration.value.enabled
);
const { isCloudFeatureEnabled } = useAccount();
const isLinearFeatureEnabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
);
const linearIntegration = useFunctionGetter(
'integrations/getIntegration',
'linear'
);
const isLinearIntegrationEnabled = computed(
const isLinearClientIdConfigured = computed(() => {
return !!linearIntegration.value?.id;
});
const isLinearConnected = computed(
() => linearIntegration.value?.enabled || false
);
@@ -241,7 +253,13 @@ onMounted(() => {
<MacrosList :conversation-id="conversationId" />
</AccordionItem>
</woot-feature-toggle>
<div v-else-if="element.name === 'linear_issues'">
<div
v-else-if="
element.name === 'linear_issues' &&
isLinearFeatureEnabled &&
isLinearClientIdConfigured
"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.LINEAR_ISSUES')"
:is-open="isContactSidebarItemOpen('is_linear_issues_open')"
@@ -250,7 +268,7 @@ onMounted(() => {
value => toggleSidebarUIState('is_linear_issues_open', value)
"
>
<LinearSetupCTA v-if="!isLinearIntegrationEnabled" />
<LinearSetupCTA v-if="!isLinearConnected" />
<LinearIssuesList v-else :conversation-id="conversationId" />
</AccordionItem>
</div>
@@ -132,6 +132,11 @@ const openPurchaseCreditsModal = () => {
purchaseCreditsModalRef.value?.open();
};
const handleTopupSuccess = () => {
// Refresh limits to show updated credit balance
fetchLimits();
};
onMounted(handleBillingPageLogic);
</script>
@@ -254,7 +259,10 @@ onMounted(handleBillingPageLogic);
</ButtonV4>
</BillingHeader>
</section>
<PurchaseCreditsModal ref="purchaseCreditsModalRef" />
<PurchaseCreditsModal
ref="purchaseCreditsModalRef"
@success="handleTopupSuccess"
/>
</template>
</SettingsLayout>
</template>
@@ -7,7 +7,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import CreditPackageCard from './CreditPackageCard.vue';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
const emit = defineEmits(['close']);
const emit = defineEmits(['close', 'success']);
const { t } = useI18n();
@@ -19,19 +19,78 @@ const TOPUP_OPTIONS = [
];
const POPULAR_CREDITS_AMOUNT = 6000;
const STEP_SELECT = 'select';
const STEP_CONFIRM = 'confirm';
const dialogRef = ref(null);
const selectedCredits = ref(null);
const isLoading = ref(false);
const currentStep = ref(STEP_SELECT);
const selectedOption = computed(() => {
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
});
const formattedAmount = computed(() => {
if (!selectedOption.value) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: selectedOption.value.currency.toUpperCase(),
}).format(selectedOption.value.amount);
});
const formattedCredits = computed(() => {
if (!selectedOption.value) return '';
return selectedOption.value.credits.toLocaleString();
});
const dialogTitle = computed(() => {
return currentStep.value === STEP_SELECT
? t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')
: t('BILLING_SETTINGS.TOPUP.CONFIRM.TITLE');
});
const dialogDescription = computed(() => {
return currentStep.value === STEP_SELECT
? t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')
: '';
});
const dialogWidth = computed(() => {
return currentStep.value === STEP_SELECT ? 'xl' : 'md';
});
const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const open = () => {
const popularOption = TOPUP_OPTIONS.find(
o => o.credits === POPULAR_CREDITS_AMOUNT
);
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
currentStep.value = STEP_SELECT;
isLoading.value = false;
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
const handleClose = () => {
emit('close');
};
const goToConfirmStep = () => {
if (!selectedOption.value) return;
currentStep.value = STEP_CONFIRM;
};
const goBackToSelectStep = () => {
currentStep.value = STEP_SELECT;
};
const handlePurchase = async () => {
if (!selectedOption.value) return;
@@ -40,9 +99,14 @@ const handlePurchase = async () => {
const response = await EnterpriseAccountAPI.createTopupCheckout(
selectedOption.value.credits
);
if (response.data.redirect_url) {
window.location.href = response.data.redirect_url;
}
close();
emit('success', response.data);
useAlert(
t('BILLING_SETTINGS.TOPUP.PURCHASE_SUCCESS', {
credits: response.data.credits,
})
);
} catch (error) {
const errorMessage =
error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR');
@@ -52,61 +116,71 @@ const handlePurchase = async () => {
}
};
const handleClose = () => {
emit('close');
};
const open = () => {
// Pre-select the most popular option
const popularOption = TOPUP_OPTIONS.find(
o => o.credits === POPULAR_CREDITS_AMOUNT
);
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="$t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')"
:description="$t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')"
width="xl"
:title="dialogTitle"
:description="dialogDescription"
:width="dialogWidth"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in TOPUP_OPTIONS"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<!-- Step 1: Select Credits Package -->
<template v-if="currentStep === 'select'">
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in TOPUP_OPTIONS"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
</template>
<!-- Step 2: Confirm Purchase -->
<template v-else>
<div class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{
$t('BILLING_SETTINGS.TOPUP.CONFIRM.DESCRIPTION', {
credits: formattedCredits,
amount: formattedAmount,
})
}}
</p>
<div class="p-2.5 rounded-lg bg-n-amber-2 border border-n-amber-6">
<p class="text-sm text-n-amber-11">
{{ $t('BILLING_SETTINGS.TOPUP.CONFIRM.INSTANT_DEDUCTION_NOTE') }}
</p>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<!-- Step 1 Footer -->
<div
v-if="currentStep === 'select'"
class="flex items-center justify-between w-full gap-3"
>
<Button
variant="faded"
color="slate"
@@ -119,6 +193,24 @@ defineExpose({ open, close });
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
class="w-full"
:disabled="!selectedCredits"
@click="goToConfirmStep"
/>
</div>
<!-- Step 2 Footer -->
<div v-else class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.GO_BACK')"
class="w-full"
:disabled="isLoading"
@click="goBackToSelectStep"
/>
<Button
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.CONFIRM_PURCHASE')"
class="w-full"
:is-loading="isLoading"
@click="handlePurchase"
/>
@@ -27,7 +27,6 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -81,7 +80,6 @@ export default {
selectedTabIndex: 0,
selectedPortalSlug: '',
showBusinessNameInput: false,
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
healthData: null,
isLoadingHealth: false,
healthError: null,
@@ -626,7 +624,7 @@ export default {
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
channel-type="Context::InboxSettings"
/>
<label v-if="isAWebWidgetInbox" class="pb-4">
@@ -7,7 +7,6 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -76,7 +75,6 @@ export default {
checked: false,
},
],
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -337,7 +335,7 @@ export default {
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
channel-type="Context::InboxSettings"
class="mb-4"
/>
<label>
@@ -5,7 +5,6 @@ import router from '../../../../index';
import NextButton from 'dashboard/components-next/button/Button.vue';
import PageHeader from '../../SettingsSubPageHeader.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
@@ -24,7 +23,6 @@ export default {
channelWelcomeTagline: '',
greetingEnabled: false,
greetingMessage: '',
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -147,7 +145,7 @@ export default {
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
channel-type="Context::InboxSettings"
class="mb-4"
/>
@@ -1,7 +1,6 @@
<script setup>
import { ref, watch } from 'vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
@@ -12,7 +11,6 @@ const props = defineProps({
});
const emit = defineEmits(['updateSignature']);
const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
const signature = ref(props.messageSignature);
watch(
() => props.messageSignature ?? '',
@@ -34,7 +32,7 @@ const updateSignature = () => {
class="message-editor h-[10rem] !px-3"
is-format-mode
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
:enabled-menu-options="customEditorMenuList"
channel-type="Context::MessageSignature"
:enable-suggestions="false"
show-image-resize-toolbar
/>
+36 -15
View File
@@ -30,21 +30,15 @@ class DataImportJob < ApplicationJob
def parse_csv_and_build_contacts
contacts = []
rejected_contacts = []
# Ensuring that importing non utf-8 characters will not throw error
data = @data_import.import_file.download
utf8_data = data.force_encoding('UTF-8')
# Ensure that the data is valid UTF-8, preserving valid characters
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
csv = CSV.parse(clean_data, headers: true)
csv.each do |row|
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
if current_contact.valid?
contacts << current_contact
else
append_rejected_contact(row, current_contact, rejected_contacts)
with_import_file do |file|
csv_reader(file).each do |row|
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
if current_contact.valid?
contacts << current_contact
else
append_rejected_contact(row, current_contact, rejected_contacts)
end
end
end
@@ -75,7 +69,7 @@ class DataImportJob < ApplicationJob
end
def generate_csv_data(rejected_contacts)
headers = CSV.parse(@data_import.import_file.download, headers: true).headers
headers = csv_headers
headers << 'errors'
return if rejected_contacts.blank?
@@ -99,4 +93,31 @@ class DataImportJob < ApplicationJob
def send_import_failed_notification_to_admin
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later
end
def csv_headers
header_row = nil
with_import_file do |file|
header_row = csv_reader(file).first
end
header_row&.headers || []
end
def csv_reader(file)
file.rewind
raw_data = file.read
utf8_data = raw_data.force_encoding('UTF-8')
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
CSV.new(StringIO.new(clean_data), headers: true)
end
def with_import_file
temp_dir = Rails.root.join('tmp/imports')
FileUtils.mkdir_p(temp_dir)
@data_import.import_file.open(tmpdir: temp_dir) do |file|
file.binmode
yield file
end
end
end
@@ -0,0 +1,51 @@
# Handles attachment processing for ConversationReplyMailer flows.
module ConversationReplyMailerAttachmentHelper
private
def process_attachments_as_files_for_email_reply
# Attachment processing for direct email replies (when replying to a single message)
#
# How attachments are handled:
# 1. Total file size (<20MB): Added directly to the email as proper attachments
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
@options[:attachments] = []
@large_attachments = []
current_total_size = 0
@message.attachments.each do |attachment|
current_total_size = handle_attachment_inline(current_total_size, attachment)
end
end
def read_blob_content(blob)
buffer = +''
blob.open do |file|
while (chunk = file.read(64.kilobytes))
buffer << chunk
end
end
buffer
end
def handle_attachment_inline(current_total_size, attachment)
blob = attachment.file.blob
return current_total_size if blob.blank?
file_size = blob.byte_size
attachment_name = attachment.file.filename.to_s
if current_total_size + file_size <= 20.megabytes
content = read_blob_content(blob)
mail.attachments[attachment_name] = {
mime_type: attachment.file.content_type || 'application/octet-stream',
content: content
}
@options[:attachments] << { name: attachment_name }
current_total_size + file_size
else
@large_attachments << attachment
current_total_size
end
end
end
@@ -1,4 +1,6 @@
module ConversationReplyMailerHelper
include ConversationReplyMailerAttachmentHelper
def prepare_mail(cc_bcc_enabled)
@options = {
to: to_emails,
@@ -27,34 +29,6 @@ module ConversationReplyMailerHelper
mail(@options)
end
def process_attachments_as_files_for_email_reply
# Attachment processing for direct email replies (when replying to a single message)
#
# How attachments are handled:
# 1. Total file size (<20MB): Added directly to the email as proper attachments
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
@options[:attachments] = []
@large_attachments = []
current_total_size = 0
@message.attachments.each do |attachment|
raw_data = attachment.file.download
attachment_name = attachment.file.filename.to_s
file_size = raw_data.bytesize
# Attach files directly until we hit 20MB total
# After reaching 20MB, send remaining files as links
if current_total_size + file_size <= 20.megabytes
mail.attachments[attachment_name] = raw_data
@options[:attachments] << { name: attachment_name }
current_total_size += file_size
else
@large_attachments << attachment
end
end
end
private
def oauth_smtp_settings
+21 -12
View File
@@ -130,30 +130,39 @@ class Channel::Telegram < ApplicationRecord
def convert_markdown_to_telegram_html(text)
# ref: https://core.telegram.org/bots/api#html-style
# escape html tags in text. We are subbing \n to <br> since commonmark will strip exta '\n'
text = CGI.escapeHTML(text.gsub("\n", '<br>'))
# Escape HTML entities first to prevent HTML injection
# This ensures only markdown syntax is converted, not raw HTML
escaped_text = CGI.escapeHTML(text)
# convert markdown to html
html = CommonMarker.render_html(text).strip
# Parse markdown with extensions:
# - strikethrough: support ~~text~~
# - hardbreaks: preserve all newlines as <br>
html = CommonMarker.render_html(escaped_text, [:HARDBREAKS], [:strikethrough]).strip
# remove all html tags except b, strong, i, em, u, ins, s, strike, del, a, code, pre, blockquote
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html, tags: %w[b strong i em u ins s strike del a code pre blockquote],
attributes: %w[href])
# Convert paragraph breaks to double newlines to preserve them
# CommonMarker creates <p> tags for paragraph breaks, but Telegram doesn't support <p>
html_with_breaks = html.gsub(%r{</p>\s*<p>}, "\n\n")
# converted escaped br tags to \n
stripped_html.gsub('&lt;br&gt;', "\n")
# Remove opening and closing <p> tags
html_with_breaks = html_with_breaks.gsub(%r{</?p>}, '')
# Sanitize to only allowed tags
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html_with_breaks, tags: %w[b strong i em u ins s strike del a code pre blockquote],
attributes: %w[href])
# Convert <br /> tags to newlines for Telegram
stripped_html.gsub(%r{<br\s*/?>}, "\n")
end
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil, business_connection_id: nil)
text_payload = convert_markdown_to_telegram_html(text)
# text is already converted to HTML by MessageContentPresenter
business_body = {}
business_body[:business_connection_id] = business_connection_id if business_connection_id
HTTParty.post("#{telegram_api_url}/sendMessage",
body: {
chat_id: chat_id,
text: text_payload,
text: text,
reply_markup: reply_markup,
parse_mode: 'HTML',
reply_to_message_id: reply_to_message_id
+1 -1
View File
@@ -53,7 +53,7 @@ class Integrations::App
when 'slack'
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
account.feature_enabled?('linear_integration') && GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
shopify_enabled?(account)
when 'leadsquared'
+11 -5
View File
@@ -1,11 +1,17 @@
class MessageContentPresenter < SimpleDelegator
def outgoing_content
return content unless should_append_survey_link?
content_to_send = if should_append_survey_link?
survey_link = survey_url(conversation.uuid)
custom_message = inbox.csat_config&.dig('message')
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
else
content
end
survey_link = survey_url(conversation.uuid)
custom_message = inbox.csat_config&.dig('message')
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
Messages::MarkdownRendererService.new(
content_to_send,
conversation.inbox.channel_type
).render
end
private
+9
View File
@@ -59,11 +59,20 @@ class Instagram::MessageText < Instagram::BaseMessageText
# We can safely create an unknown contact, making this integration work.
return unknown_user(ig_scope_id) if error_code == 9010
# Handle error code 100: Object doesn't exist or missing permissions
# This typically occurs when trying to fetch a user that doesn't exist or has privacy restrictions
# We can safely create an unknown contact, similar to error 9010
return unknown_user(ig_scope_id) if error_code == 100
Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id} ig_scope_id #{ig_scope_id}")
Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}")
exception = StandardError.new("#{error_message} (Code: #{error_code}, IG Scope ID: #{ig_scope_id})")
ChatwootExceptionTracker.new(exception, account: @inbox.account).capture_exception
# Explicitly return empty hash for any unhandled error codes
# This prevents the exception tracker result from being returned
{}
end
def base_uri
@@ -0,0 +1,64 @@
class Messages::MarkdownRendererService
CHANNEL_RENDERERS = {
'Channel::Email' => :render_html,
'Channel::WebWidget' => :render_html,
'Channel::Telegram' => :render_telegram_html,
'Channel::Whatsapp' => :render_whatsapp,
'Channel::FacebookPage' => :render_instagram,
'Channel::Instagram' => :render_instagram,
'Channel::Line' => :render_line,
'Channel::TwitterProfile' => :render_plain_text,
'Channel::Sms' => :render_plain_text,
'Channel::TwilioSms' => :render_plain_text
}.freeze
def initialize(content, channel_type)
@content = content
@channel_type = channel_type
end
def render
return @content if @content.blank?
renderer_method = CHANNEL_RENDERERS[@channel_type]
renderer_method ? send(renderer_method) : @content
end
private
def commonmarker_doc
@commonmarker_doc ||= CommonMarker.render_doc(@content, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
end
def render_html
markdown_renderer = BaseMarkdownRenderer.new
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
markdown_renderer.render(doc)
end
def render_telegram_html
renderer = Messages::MarkdownRenderers::TelegramRenderer.new
doc = CommonMarker.render_doc(@content, [:STRIKETHROUGH_DOUBLE_TILDE], [:strikethrough])
renderer.render(doc).gsub(/\n+\z/, '')
end
def render_whatsapp
renderer = Messages::MarkdownRenderers::WhatsAppRenderer.new
renderer.render(commonmarker_doc).gsub(/\n+\z/, '')
end
def render_instagram
renderer = Messages::MarkdownRenderers::InstagramRenderer.new
renderer.render(commonmarker_doc).gsub(/\n+\z/, '')
end
def render_line
renderer = Messages::MarkdownRenderers::LineRenderer.new
renderer.render(commonmarker_doc).gsub(/\n+\z/, '')
end
def render_plain_text
renderer = Messages::MarkdownRenderers::PlainTextRenderer.new
renderer.render(commonmarker_doc).gsub(/\n+\z/, '')
end
end
@@ -0,0 +1,39 @@
class Messages::MarkdownRenderers::BaseMarkdownRenderer < CommonMarker::Renderer
def document(_node)
out(:children)
end
def paragraph(_node)
out(:children)
cr
end
def text(node)
out(node.string_content)
end
def softbreak(_node)
out(' ')
end
def linebreak(_node)
out("\n")
end
def strikethrough(_node)
out('<del>')
out(:children)
out('</del>')
end
def method_missing(method_name, node = nil, *args, **kwargs, &)
return super unless node.is_a?(CommonMarker::Node)
out(:children)
cr unless %i[text softbreak linebreak].include?(node.type)
end
def respond_to_missing?(_method_name, _include_private = false)
true
end
end
@@ -0,0 +1,44 @@
class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def strong(_node)
out('*', :children, '*')
end
def emph(_node)
out('_', :children, '_')
end
def code(node)
out(node.string_content)
end
def link(node)
out(node.url)
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('- ', :children)
end
cr
end
def blockquote(_node)
out(:children)
cr
end
end
@@ -0,0 +1,36 @@
class Messages::MarkdownRenderers::LineRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def strong(_node)
out(' *', :children, '* ')
end
def emph(_node)
out(' _', :children, '_ ')
end
def code(node)
out(' `', node.string_content, '` ')
end
def link(node)
out(node.url)
end
def list(_node)
out(:children)
cr
end
def list_item(_node)
out(:children)
cr
end
def code_block(node)
out(' ```', "\n", node.string_content, '``` ', "\n")
end
def blockquote(_node)
out(:children)
cr
end
end
@@ -0,0 +1,58 @@
class Messages::MarkdownRenderers::PlainTextRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def link(node)
out(:children)
out(' ', node.url) if node.url.present?
end
def strong(_node)
out(:children)
end
def emph(_node)
out(:children)
end
def code(node)
out(node.string_content)
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('- ', :children)
end
cr
end
def blockquote(_node)
out(:children)
cr
end
def code_block(node)
out(node.string_content, "\n")
end
def header(_node)
out(:children)
cr
end
def thematic_break(_node)
out("\n")
end
end
@@ -0,0 +1,60 @@
class Messages::MarkdownRenderers::TelegramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def strong(_node)
out('<strong>', :children, '</strong>')
end
def emph(_node)
out('<em>', :children, '</em>')
end
def code(node)
out('<code>', node.string_content, '</code>')
end
def link(node)
out('<a href="', node.url, '">', :children, '</a>')
end
def strikethrough(_node)
out('<del>', :children, '</del>')
end
def blockquote(_node)
out('<blockquote>', :children, '</blockquote>')
end
def code_block(node)
out('<pre>', node.string_content, '</pre>')
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('• ', :children)
end
cr
end
def header(_node)
out('<strong>', :children, '</strong>')
cr
end
def softbreak(_node)
out("\n")
end
end
@@ -0,0 +1,32 @@
class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def strong(_node)
out('*', :children, '*')
end
def emph(_node)
out('_', :children, '_')
end
def code(node)
out('`', node.string_content, '`')
end
def link(node)
out(node.url)
end
def list(_node)
out(:children)
cr
end
def list_item(_node)
out('- ', :children)
cr
end
def blockquote(_node)
out('> ', :children)
cr
end
end
@@ -96,11 +96,16 @@ class Telegram::SendAttachmentsService
# Telegram picks up the file name from original field name, so we need to save the file with the original name.
# Hence not using Tempfile here.
def save_attachment_to_tempfile(attachment)
raw_data = attachment.file.download
temp_dir = Rails.root.join('tmp/uploads')
temp_dir = Rails.root.join('tmp/uploads', "telegram-#{attachment.message_id}")
FileUtils.mkdir_p(temp_dir)
temp_file_path = File.join(temp_dir, attachment.file.filename.to_s)
File.write(temp_file_path, raw_data, mode: 'wb')
File.open(temp_file_path, 'wb') do |file|
attachment.file.blob.open do |blob_file|
IO.copy_stream(blob_file, file)
end
end
temp_file_path
end
@@ -1,7 +1,7 @@
<% if @message.content_attributes.dig('email', 'html_content', 'reply').present? %>
<%= @message.content_attributes.dig('email', 'html_content', 'reply').html_safe %>
<% elsif @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<%= @message.outgoing_content.html_safe %>
<% end %>
<% if @large_attachments.present? %>
<p>Attachments:</p>
+2
View File
@@ -127,6 +127,8 @@ en:
invalid_credits: Invalid credits amount
invalid_option: Invalid topup option
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
stripe_customer_not_configured: Stripe customer not configured
no_payment_method: No payment methods found. Please add a payment method before making a purchase.
profile:
mfa:
enabled: MFA enabled successfully
-1
View File
@@ -24,7 +24,6 @@ Rails.application.routes.draw do
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
get '/app/accounts/:account_id/settings/billing', to: 'dashboard#index', as: 'app_account_billing_settings'
resource :widget, only: [:show]
namespace :survey do
@@ -59,10 +59,15 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
return render json: { error: I18n.t('errors.topup.credits_required') }, status: :unprocessable_entity if params[:credits].blank?
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
redirect_url = service.create_checkout_session(credits: params[:credits].to_i)
render json: { redirect_url: redirect_url }
rescue Enterprise::Billing::TopupCheckoutService::Error => e
Rails.logger.error("Topup checkout failed for account #{@account.id}: #{e.message}")
result = service.create_checkout_session(credits: params[:credits].to_i)
@account.reload
render json: result.merge(
id: @account.id,
limits: @account.limits,
custom_attributes: @account.custom_attributes
)
rescue Enterprise::Billing::TopupCheckoutService::Error, Stripe::StripeError => e
render_could_not_create_error(e.message)
end
+64 -20
View File
@@ -1,31 +1,83 @@
module Captain::ChatHelper
include Integrations::LlmInstrumentation
include Captain::ToolExecutionHelper
include Captain::ChatResponseHelper
def request_chat_completion
log_chat_completion_request
chat = build_chat
add_messages_to_chat(chat)
with_agent_session do
response = instrument_llm_call(instrumentation_params) do
@client.chat(
parameters: chat_parameters
)
end
handle_response(response)
response = chat.ask(conversation_messages.last[:content])
build_response(response)
end
rescue StandardError => e
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
raise e
end
def instrumentation_params
private
def build_chat
llm_chat = chat(model: @model, temperature: temperature)
llm_chat.with_params(response_format: { type: 'json_object' })
llm_chat = setup_tools(llm_chat)
setup_system_instructions(llm_chat)
setup_event_handlers(llm_chat)
llm_chat
end
def setup_tools(chat)
@tools&.each do |tool|
chat.with_tool(tool)
end
chat
end
def setup_system_instructions(chat)
system_messages = @messages.select { |m| m[:role] == 'system' || m[:role] == :system }
combined_instructions = system_messages.pluck(:content).join("\n\n")
chat.with_instructions(combined_instructions)
end
def setup_event_handlers(chat)
chat.on_new_message { start_llm_turn_span(instrumentation_params(chat)) }
chat.on_end_message { |message| end_llm_turn_span(message) }
chat.on_tool_call { |tool_call| handle_tool_call(tool_call) }
chat.on_tool_result { |result| handle_tool_result(result) }
chat
end
def handle_tool_call(tool_call)
persist_thinking_message(tool_call)
start_tool_span(tool_call)
@pending_tool_calls ||= []
@pending_tool_calls.push(tool_call)
end
def handle_tool_result(result)
end_tool_span(result)
persist_tool_completion
end
def add_messages_to_chat(chat)
conversation_messages[0...-1].each do |msg|
chat.add_message(role: msg[:role].to_sym, content: msg[:content])
end
end
def instrumentation_params(chat = nil)
{
span_name: "llm.captain.#{feature_name}",
account_id: resolved_account_id,
conversation_id: @conversation_id,
feature_name: feature_name,
model: @model,
messages: @messages,
messages: chat ? chat.messages.map { |m| { role: m.role.to_s, content: m.content.to_s } } : @messages,
temperature: temperature,
metadata: {
assistant_id: @assistant&.id
@@ -33,14 +85,8 @@ module Captain::ChatHelper
}
end
def chat_parameters
{
model: @model,
messages: @messages,
tools: @tool_registry&.registered_tools || [],
response_format: { type: 'json_object' },
temperature: temperature
}
def conversation_messages
@messages.reject { |m| m[:role] == 'system' || m[:role] == :system }
end
def temperature
@@ -51,8 +97,6 @@ module Captain::ChatHelper
@account&.id || @assistant&.account_id
end
private
# Ensures all LLM calls and tool executions within an agentic loop
# are grouped under a single trace/session in Langfuse.
#
@@ -78,7 +122,7 @@ module Captain::ChatHelper
def log_chat_completion_request
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion
for messages #{@messages} with #{@tool_registry&.registered_tools&.length || 0} tools
for messages #{@messages} with #{@tools&.length || 0} tools
"
)
end
@@ -0,0 +1,52 @@
module Captain::ChatResponseHelper
private
def build_response(response)
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
parsed = parse_json_response(response.content)
persist_message(parsed, 'assistant')
parsed
end
def parse_json_response(content)
content = content.gsub('```json', '').gsub('```', '')
content = content.strip
JSON.parse(content)
rescue JSON::ParserError => e
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error parsing JSON response: #{e.message}"
{ 'content' => content }
end
def persist_thinking_message(tool_call)
return if @copilot_thread.blank?
tool_name = tool_call.name.to_s
persist_message(
{
'content' => "Using #{tool_name}",
'function_name' => tool_name
},
'assistant_thinking'
)
end
def persist_tool_completion
return if @copilot_thread.blank?
tool_call = @pending_tool_calls&.pop
return unless tool_call
tool_name = tool_call.name.to_s
persist_message(
{
'content' => "Completed #{tool_name}",
'function_name' => tool_name
},
'assistant_thinking'
)
end
end
@@ -1,83 +0,0 @@
module Captain::ToolExecutionHelper
private
def handle_response(response)
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
message = response.dig('choices', 0, 'message')
if message['tool_calls']
process_tool_calls(message['tool_calls'])
else
message = JSON.parse(message['content'].strip)
persist_message(message, 'assistant')
message
end
end
def process_tool_calls(tool_calls)
append_tool_calls(tool_calls)
tool_calls.each { |tool_call| process_tool_call(tool_call) }
request_chat_completion
end
def process_tool_call(tool_call)
arguments = JSON.parse(tool_call['function']['arguments'])
function_name = tool_call['function']['name']
tool_call_id = tool_call['id']
if @tool_registry.respond_to?(function_name)
execute_tool(function_name, arguments, tool_call_id)
else
process_invalid_tool_call(function_name, tool_call_id)
end
end
def execute_tool(function_name, arguments, tool_call_id)
persist_tool_status(function_name, 'captain.copilot.using_tool')
result = perform_tool_call(function_name, arguments)
persist_tool_status(function_name, 'captain.copilot.completed_tool_call')
append_tool_response(result, tool_call_id)
end
def perform_tool_call(function_name, arguments)
instrument_tool_call(function_name, arguments) do
@tool_registry.send(function_name, arguments)
end
rescue StandardError => e
Rails.logger.error "Tool #{function_name} failed: #{e.message}"
"Error executing #{function_name}: #{e.message}"
end
def persist_tool_status(function_name, translation_key)
persist_message(
{
content: I18n.t(translation_key, function_name: function_name),
function_name: function_name
},
'assistant_thinking'
)
end
def append_tool_calls(tool_calls)
@messages << {
role: 'assistant',
tool_calls: tool_calls
}
end
def process_invalid_tool_call(function_name, tool_call_id)
persist_message(
{ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name },
'assistant_thinking'
)
append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
end
def append_tool_response(content, tool_call_id)
@messages << {
role: 'tool',
tool_call_id: tool_call_id,
content: content
}
end
end
@@ -1,6 +1,4 @@
require 'openai'
class Captain::Copilot::ChatService < Llm::BaseOpenAiService
class Captain::Copilot::ChatService < Llm::BaseAiService
include Captain::ChatHelper
attr_reader :assistant, :account, :user, :copilot_thread, :previous_history, :messages
@@ -14,9 +12,10 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
@copilot_thread = nil
@previous_history = []
@conversation_id = config[:conversation_id]
setup_user(config)
setup_message_history(config)
register_tools
@tools = build_tools
@messages = build_messages(config)
end
@@ -60,16 +59,19 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
end
end
def register_tools
@tool_registry = Captain::ToolRegistryService.new(@assistant, user: @user)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetArticleService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetContactService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetConversationService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchArticlesService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchContactsService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchConversationsService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchLinearIssuesService)
def build_tools
tools = []
tools << Captain::Tools::SearchDocumentationService.new(@assistant, user: @user)
tools << Captain::Tools::Copilot::GetConversationService.new(@assistant, user: @user)
tools << Captain::Tools::Copilot::SearchConversationsService.new(@assistant, user: @user)
tools << Captain::Tools::Copilot::GetContactService.new(@assistant, user: @user)
tools << Captain::Tools::Copilot::GetArticleService.new(@assistant, user: @user)
tools << Captain::Tools::Copilot::SearchArticlesService.new(@assistant, user: @user)
tools << Captain::Tools::Copilot::SearchContactsService.new(@assistant, user: @user)
tools << Captain::Tools::Copilot::SearchLinearIssuesService.new(@assistant, user: @user)
tools.select(&:active?)
end
def system_message
@@ -77,12 +79,16 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
role: 'system',
content: Captain::Llm::SystemPromptsService.copilot_response_generator(
@assistant.config['product_name'],
@tool_registry.tools_summary,
tools_summary,
@assistant.config
)
}
end
def tools_summary
@tools.map { |tool| "- #{tool.class.name}: #{tool.class.description}" }.join("\n")
end
def account_id_context
{
role: 'system',
@@ -1,6 +1,4 @@
require 'openai'
class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
class Captain::Llm::AssistantChatService < Llm::BaseAiService
include Captain::ChatHelper
def initialize(assistant: nil, conversation_id: nil)
@@ -8,9 +6,10 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
@assistant = assistant
@conversation_id = conversation_id
@messages = [system_message]
@response = ''
register_tools
@tools = build_tools
end
# additional_message: A single message (String) from the user that should be appended to the chat.
@@ -28,9 +27,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
private
def register_tools
@tool_registry = Captain::ToolRegistryService.new(@assistant, user: nil)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
def build_tools
[Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
end
def system_message
@@ -1,4 +1,4 @@
class Captain::Llm::ContactAttributesService < Llm::BaseOpenAiService
class Captain::Llm::ContactAttributesService < Llm::LegacyBaseOpenAiService
def initialize(assistant, conversation)
super()
@assistant = assistant
@@ -1,4 +1,4 @@
class Captain::Llm::ContactNotesService < Llm::BaseOpenAiService
class Captain::Llm::ContactNotesService < Llm::LegacyBaseOpenAiService
def initialize(assistant, conversation)
super()
@assistant = assistant
@@ -1,4 +1,4 @@
class Captain::Llm::ConversationFaqService < Llm::BaseOpenAiService
class Captain::Llm::ConversationFaqService < Llm::LegacyBaseOpenAiService
DISTANCE_THRESHOLD = 0.3
def initialize(assistant, conversation)
@@ -1,6 +1,6 @@
require 'openai'
class Captain::Llm::EmbeddingService < Llm::BaseOpenAiService
class Captain::Llm::EmbeddingService < Llm::LegacyBaseOpenAiService
class EmbeddingsError < StandardError; end
def self.embedding_model
@@ -1,4 +1,4 @@
class Captain::Llm::FaqGeneratorService < Llm::BaseOpenAiService
class Captain::Llm::FaqGeneratorService < Llm::LegacyBaseOpenAiService
def initialize(content, language = 'english')
super()
@language = language
@@ -1,4 +1,4 @@
class Captain::Llm::PaginatedFaqGeneratorService < Llm::BaseOpenAiService
class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
# Default pages per chunk - easily configurable
DEFAULT_PAGES_PER_CHUNK = 10
MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops
@@ -1,4 +1,4 @@
class Captain::Llm::PdfProcessingService < Llm::BaseOpenAiService
class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
def initialize(document)
super()
@document = document
@@ -29,12 +29,16 @@ class Captain::Llm::PdfProcessingService < Llm::BaseOpenAiService
end
end
def with_tempfile(&)
def with_tempfile
Tempfile.create(['pdf_upload', '.pdf'], binmode: true) do |temp_file|
temp_file.write(document.pdf_file.download)
temp_file.close
document.pdf_file.blob.open do |blob_file|
IO.copy_stream(blob_file, temp_file)
end
File.open(temp_file.path, 'rb', &)
temp_file.flush
temp_file.rewind
yield temp_file
end
end
end
@@ -1,4 +1,4 @@
class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseOpenAiService
class Captain::Onboarding::WebsiteAnalyzerService < Llm::LegacyBaseOpenAiService
MAX_CONTENT_LENGTH = 8000
def initialize(website_url)
@@ -0,0 +1,26 @@
class Captain::Tools::BaseTool < RubyLLM::Tool
attr_accessor :assistant
def initialize(assistant, user: nil)
@assistant = assistant
@user = user
super()
end
def active?
true
end
private
def user_has_permission(permission)
return false if @user.blank?
account_user = AccountUser.find_by(account_id: @assistant.account_id, user_id: @user.id)
return false if account_user.blank?
return account_user.custom_role.permissions.include?(permission) if account_user.custom_role.present?
account_user.administrator? || account_user.agent?
end
end
@@ -1,32 +1,11 @@
class Captain::Tools::Copilot::GetArticleService < Captain::Tools::BaseService
def name
class Captain::Tools::Copilot::GetArticleService < Captain::Tools::BaseTool
def self.name
'get_article'
end
description 'Get details of an article including its content and metadata'
param :article_id, type: :number, desc: 'The ID of the article to retrieve', required: true
def description
'Get details of an article including its content and metadata'
end
def parameters
{
type: 'object',
properties: {
article_id: {
type: 'number',
description: 'The ID of the article to retrieve'
}
},
required: %w[article_id]
}
end
def execute(arguments)
article_id = arguments['article_id']
Rails.logger.info { "#{self.class.name}: Article ID: #{article_id}" }
return 'Missing required parameters' if article_id.blank?
def execute(article_id:)
article = Article.find_by(id: article_id, account_id: @assistant.account_id)
return 'Article not found' if article.nil?
@@ -1,32 +1,11 @@
class Captain::Tools::Copilot::GetContactService < Captain::Tools::BaseService
def name
class Captain::Tools::Copilot::GetContactService < Captain::Tools::BaseTool
def self.name
'get_contact'
end
description 'Get details of a contact including their profile information'
param :contact_id, type: :number, desc: 'The ID of the contact to retrieve', required: true
def description
'Get details of a contact including their profile information'
end
def parameters
{
type: 'object',
properties: {
contact_id: {
type: 'number',
description: 'The ID of the contact to retrieve'
}
},
required: %w[contact_id]
}
end
def execute(arguments)
contact_id = arguments['contact_id']
Rails.logger.info "#{self.class.name}: Contact ID: #{contact_id}"
return 'Missing required parameters' if contact_id.blank?
def execute(contact_id:)
contact = Contact.find_by(id: contact_id, account_id: @assistant.account_id)
return 'Contact not found' if contact.nil?
@@ -1,32 +1,12 @@
class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseService
def name
class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseTool
def self.name
'get_conversation'
end
description 'Get details of a conversation including messages and contact information'
def description
'Get details of a conversation including messages and contact information'
end
def parameters
{
type: 'object',
properties: {
conversation_id: {
type: 'number',
description: 'The ID of the conversation to retrieve'
}
},
required: %w[conversation_id]
}
end
def execute(arguments)
conversation_id = arguments['conversation_id']
Rails.logger.info "#{self.class.name}: Conversation ID: #{conversation_id}"
return 'Missing required parameters' if conversation_id.blank?
param :conversation_id, type: :integer, desc: 'ID of the conversation to retrieve', required: true
def execute(conversation_id:)
conversation = Conversation.find_by(display_id: conversation_id, account_id: @assistant.account_id)
return 'Conversation not found' if conversation.blank?
@@ -1,36 +1,18 @@
class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseService
def name
class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseTool
def self.name
'search_articles'
end
description 'Search articles based on parameters'
param :query, desc: 'Search articles by title or content (partial match)', required: false
param :category_id, type: :number, desc: 'Filter articles by category ID', required: false
param :status, type: :string, desc: 'Filter articles by status - MUST BE ONE OF: draft, published, archived', required: false
def description
'Search articles based on parameters'
end
def parameters
{
type: 'object',
properties: properties,
required: ['query']
}
end
def execute(arguments)
query = arguments['query']
category_id = arguments['category_id']
status = arguments['status']
Rails.logger.info "#{self.class.name}: Query: #{query}, Category ID: #{category_id}, Status: #{status}"
return 'Missing required parameters' if query.blank?
articles = fetch_articles(query, category_id, status)
def execute(query: nil, category_id: nil, status: nil)
articles = fetch_articles(query: query, category_id: category_id, status: status)
return 'No articles found' unless articles.exists?
total_count = articles.count
articles = articles.limit(100)
<<~RESPONSE
#{total_count > 100 ? "Found #{total_count} articles (showing first 100)" : "Total number of articles: #{total_count}"}
#{articles.map(&:to_llm_text).join("\n---\n")}
@@ -43,29 +25,11 @@ class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseServi
private
def fetch_articles(query, category_id, status)
def fetch_articles(query:, category_id:, status:)
articles = Article.where(account_id: @assistant.account_id)
articles = articles.where('title ILIKE :query OR content ILIKE :query', query: "%#{query}%") if query.present?
articles = articles.where(category_id: category_id) if category_id.present?
articles = articles.where(status: status) if status.present?
articles
end
def properties
{
query: {
type: 'string',
description: 'Search articles by title or content (partial match)'
},
category_id: {
type: 'number',
description: 'Filter articles by category ID'
},
status: {
type: 'string',
enum: %w[draft published archived],
description: 'Filter articles by status'
}
}
end
end
@@ -1,27 +1,14 @@
class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseService
def name
class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseTool
def self.name
'search_contacts'
end
def description
'Search contacts based on query parameters'
end
def parameters
{
type: 'object',
properties: properties,
required: []
}
end
def execute(arguments)
email = arguments['email']
phone_number = arguments['phone_number']
name = arguments['name']
Rails.logger.info "#{self.class.name} Email: #{email}, Phone Number: #{phone_number}, Name: #{name}"
description 'Search contacts based on query parameters'
param :email, type: :string, desc: 'Filter contacts by email'
param :phone_number, type: :string, desc: 'Filter contacts by phone number'
param :name, type: :string, desc: 'Filter contacts by name (partial match)'
def execute(email: nil, phone_number: nil, name: nil)
contacts = Contact.where(account_id: @assistant.account_id)
contacts = contacts.where(email: email) if email.present?
contacts = contacts.where(phone_number: phone_number) if phone_number.present?
@@ -39,23 +26,4 @@ class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseServi
def active?
user_has_permission('contact_manage')
end
private
def properties
{
email: {
type: 'string',
description: 'Filter contacts by email'
},
phone_number: {
type: 'string',
description: 'Filter contacts by phone number'
},
name: {
type: 'string',
description: 'Filter contacts by name (partial match)'
}
}
end
end
@@ -1,26 +1,15 @@
class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::BaseService
def name
'search_conversations'
class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::BaseTool
def self.name
'search_conversation'
end
description 'Search conversations based on parameters'
def description
'Search conversations based on parameters'
end
def parameters
{
type: 'object',
properties: properties,
required: []
}
end
def execute(arguments)
status = arguments['status']
contact_id = arguments['contact_id']
priority = arguments['priority']
labels = arguments['labels']
param :status, type: :string, desc: 'Status of the conversation'
param :contact_id, type: :number, desc: 'Contact id'
param :priority, type: :string, desc: 'Priority of conversation'
param :labels, type: :string, desc: 'Labels available'
def execute(status: nil, contact_id: nil, priority: nil, labels: nil)
conversations = get_conversations(status, contact_id, priority, labels)
return 'No conversations found' unless conversations.exists?
@@ -58,13 +47,4 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
@assistant.account
).perform
end
def properties
{
contact_id: { type: 'number', description: 'Filter conversations by contact ID' },
status: { type: 'string', enum: %w[open resolved pending snoozed], description: 'Filter conversations by status' },
priority: { type: 'string', enum: %w[low medium high urgent], description: 'Filter conversations by priority' },
labels: { type: 'array', items: { type: 'string' }, description: 'Filter conversations by labels' }
}
end
end
@@ -1,34 +1,14 @@
class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseService
def name
class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseTool
def self.name
'search_linear_issues'
end
def description
'Search Linear issues based on a search term'
end
description 'Search Linear issues based on a search term'
param :term, type: :string, desc: 'The search term to find Linear issues', required: true
def parameters
{
type: 'object',
properties: {
term: {
type: 'string',
description: 'The search term to find Linear issues'
}
},
required: %w[term]
}
end
def execute(arguments)
def execute(term:)
return 'Linear integration is not enabled' unless active?
term = arguments['term']
Rails.logger.info "#{self.class.name}: Service called with the search term #{term}"
return 'Missing required parameters' if term.blank?
linear_service = Integrations::Linear::ProcessorService.new(account: @assistant.account)
result = linear_service.search_issue(term)
@@ -1,27 +1,12 @@
class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseService
def name
class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
def self.name
'search_documentation'
end
description 'Search and retrieve documentation from knowledge base'
def description
'Search and retrieve documentation from knowledge base'
end
param :query, desc: 'Search Query', required: true
def parameters
{
type: 'object',
properties: {
search_query: {
type: 'string',
description: 'The search query to look up in the documentation.'
}
},
required: ['search_query']
}
end
def execute(arguments)
query = arguments['search_query']
def execute(query:)
Rails.logger.info { "#{self.class.name}: #{query}" }
responses = assistant.responses.approved.search(query)
@@ -34,8 +34,6 @@ class Enterprise::Billing::HandleStripeEventService
process_subscription_updated
when 'customer.subscription.deleted'
process_subscription_deleted
when 'checkout.session.completed'
process_checkout_session_completed
else
Rails.logger.debug { "Unhandled event type: #{event.type}" }
end
@@ -52,15 +50,19 @@ class Enterprise::Billing::HandleStripeEventService
previous_usage = capture_previous_usage
update_account_attributes(subscription, plan)
update_plan_features
handle_subscription_credits(plan, previous_usage)
account.reset_response_usage
if billing_period_renewed?
ActiveRecord::Base.transaction do
handle_subscription_credits(plan, previous_usage)
account.reset_response_usage
end
elsif plan_changed?
handle_plan_change_credits(plan, previous_usage)
end
end
def capture_previous_usage
{
responses: account.custom_attributes['captain_responses_usage'].to_i,
monthly: current_plan_credits[:responses]
}
{ responses: account.custom_attributes['captain_responses_usage'].to_i, monthly: current_plan_credits[:responses] }
end
def current_plan_credits
@@ -73,15 +75,15 @@ class Enterprise::Billing::HandleStripeEventService
def update_account_attributes(subscription, plan)
# https://stripe.com/docs/api/subscriptions/object
account.update(
custom_attributes: {
stripe_customer_id: subscription.customer,
stripe_price_id: subscription['plan']['id'],
stripe_product_id: subscription['plan']['product'],
plan_name: plan['name'],
subscribed_quantity: subscription['quantity'],
subscription_status: subscription['status'],
subscription_ends_on: Time.zone.at(subscription['current_period_end'])
}
custom_attributes: account.custom_attributes.merge(
'stripe_customer_id' => subscription.customer,
'stripe_price_id' => subscription['plan']['id'],
'stripe_product_id' => subscription['plan']['product'],
'plan_name' => plan['name'],
'subscribed_quantity' => subscription['quantity'],
'subscription_status' => subscription['status'],
'subscription_ends_on' => Time.zone.at(subscription['current_period_end'])
)
)
end
@@ -92,31 +94,6 @@ class Enterprise::Billing::HandleStripeEventService
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end
def process_checkout_session_completed
session = @event.data.object
metadata = session.metadata
# Only process topup checkout sessions
return unless metadata.present? && metadata['topup'] == 'true'
topup_account = Account.find_by(id: metadata['account_id'])
return if topup_account.blank?
credits = metadata['credits'].to_i
amount_cents = metadata['amount_cents'].to_i
currency = metadata['currency'] || 'usd'
Rails.logger.info("Processing topup for account #{topup_account.id}: #{credits} credits, #{amount_cents} cents")
Enterprise::Billing::TopupFulfillmentService.new(account: topup_account).fulfill(
credits: credits,
amount_cents: amount_cents,
currency: currency
)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: topup_account).capture_exception
raise
end
def update_plan_features
if default_plan?
disable_all_premium_features
@@ -158,6 +135,18 @@ class Enterprise::Billing::HandleStripeEventService
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
end
def handle_plan_change_credits(new_plan, previous_usage)
current_limits = account.limits || {}
current_credits = current_limits['captain_responses'].to_i
previous_plan_credits = previous_usage[:monthly]
new_plan_credits = get_plan_credits(new_plan['name'])[:responses]
updated_credits = current_credits - previous_plan_credits + new_plan_credits
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
end
def get_plan_credits(plan_name)
config = InstallationConfig.find_by(name: CAPTAIN_CLOUD_PLAN_LIMITS).value
config = JSON.parse(config) if config.is_a?(String)
@@ -168,20 +157,12 @@ class Enterprise::Billing::HandleStripeEventService
plan_name = account.custom_attributes['plan_name']
return if plan_name.blank?
# Enable features based on plan hierarchy
case plan_name
when 'Startups'
# Startups plan gets the basic features
account.enable_features(*STARTUP_PLAN_FEATURES)
when 'Startups' then account.enable_features(*STARTUP_PLAN_FEATURES)
when 'Business'
# Business plan gets Startups features + Business features
account.enable_features(*STARTUP_PLAN_FEATURES)
account.enable_features(*BUSINESS_PLAN_FEATURES)
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES)
when 'Enterprise'
# Enterprise plan gets all features
account.enable_features(*STARTUP_PLAN_FEATURES)
account.enable_features(*BUSINESS_PLAN_FEATURES)
account.enable_features(*ENTERPRISE_PLAN_FEATURES)
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES, *ENTERPRISE_PLAN_FEATURES)
end
end
@@ -189,6 +170,25 @@ class Enterprise::Billing::HandleStripeEventService
@subscription ||= @event.data.object
end
def previous_attributes
@previous_attributes ||= JSON.parse((@event.data.previous_attributes || {}).to_json)
end
def plan_changed?
return false if previous_attributes['plan'].blank?
previous_plan_id = previous_attributes.dig('plan', 'id')
current_plan_id = subscription['plan']['id']
previous_plan_id != current_plan_id
end
def billing_period_renewed?
return false if previous_attributes['current_period_start'].blank?
previous_attributes['current_period_start'] != subscription['current_period_start']
end
def account
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
end
@@ -1,6 +1,5 @@
class Enterprise::Billing::TopupCheckoutService
include BillingHelper
include Rails.application.routes.url_helpers
class Error < StandardError; end
@@ -15,8 +14,14 @@ class Enterprise::Billing::TopupCheckoutService
def create_checkout_session(credits:)
topup_option = validate_and_find_topup_option(credits)
session = create_stripe_session(topup_option, credits)
session.url
charge_customer(topup_option, credits)
fulfill_credits(credits, topup_option)
{
credits: credits,
amount: topup_option[:amount],
currency: topup_option[:currency]
}
end
private
@@ -24,69 +29,60 @@ class Enterprise::Billing::TopupCheckoutService
def validate_and_find_topup_option(credits)
raise Error, I18n.t('errors.topup.invalid_credits') unless credits.to_i.positive?
raise Error, I18n.t('errors.topup.plan_not_eligible') if default_plan?(account)
raise Error, I18n.t('errors.topup.stripe_customer_not_configured') if stripe_customer_id.blank?
topup_option = find_topup_option(credits)
raise Error, I18n.t('errors.topup.invalid_option') unless topup_option
# Validate payment method exists
validate_payment_method!
topup_option
end
def create_stripe_session(topup_option, credits)
Stripe::Checkout::Session.create(
def validate_payment_method!
customer = Stripe::Customer.retrieve(stripe_customer_id)
return if customer.invoice_settings.default_payment_method.present? || customer.default_source.present?
# Auto-set first payment method as default if available
payment_methods = Stripe::PaymentMethod.list(customer: stripe_customer_id, limit: 1)
raise Error, I18n.t('errors.topup.no_payment_method') if payment_methods.data.empty?
Stripe::Customer.update(stripe_customer_id, invoice_settings: { default_payment_method: payment_methods.data.first.id })
end
def charge_customer(topup_option, credits)
amount_cents = (topup_option[:amount] * 100).to_i
currency = topup_option[:currency]
description = "AI Credits Topup: #{credits} credits"
invoice = Stripe::Invoice.create(
customer: stripe_customer_id,
mode: 'payment',
line_items: [build_line_item(topup_option, credits)],
success_url: success_url,
cancel_url: cancel_url,
metadata: session_metadata(credits, topup_option),
payment_method_types: ['card'],
# Show saved payment methods and allow saving new ones
saved_payment_method_options: {
payment_method_save: 'enabled',
allow_redisplay_filters: %w[always limited]
},
# Create invoice for this payment so it appears in customer portal
invoice_creation: build_invoice_creation_data(credits, topup_option)
currency: currency,
collection_method: 'charge_automatically',
auto_advance: false,
description: description
)
Stripe::InvoiceItem.create(
customer: stripe_customer_id,
amount: amount_cents,
currency: currency,
invoice: invoice.id,
description: description
)
Stripe::Invoice.finalize_invoice(invoice.id, { auto_advance: false })
Stripe::Invoice.pay(invoice.id)
end
def build_invoice_creation_data(credits, topup_option)
{
enabled: true,
invoice_data: {
description: "AI Credits Topup: #{credits} credits",
metadata: session_metadata(credits, topup_option)
}
}
end
def build_line_item(topup_option, credits)
{
price_data: {
currency: topup_option[:currency],
unit_amount: (topup_option[:amount] * 100).to_i,
product_data: { name: "AI Credits Topup: #{credits} credits" }
},
quantity: 1
}
end
def session_metadata(credits, topup_option)
{
account_id: account.id.to_s,
credits: credits.to_s,
amount_cents: (topup_option[:amount] * 100).to_s,
currency: topup_option[:currency],
topup: 'true'
}
end
def success_url
app_account_billing_settings_url(account_id: account.id, topup: 'success')
end
def cancel_url
app_account_billing_settings_url(account_id: account.id)
def fulfill_credits(credits, topup_option)
Enterprise::Billing::TopupFulfillmentService.new(account: account).fulfill(
credits: credits,
amount_cents: (topup_option[:amount] * 100).to_i,
currency: topup_option[:currency]
)
end
def stripe_customer_id
@@ -6,6 +6,8 @@ class Enterprise::Billing::TopupFulfillmentService
create_stripe_credit_grant(credits, amount_cents, currency)
update_account_credits(credits)
end
Rails.logger.info("Topup fulfilled for account #{account.id}: #{credits} credits, #{amount_cents} cents")
end
private
@@ -1,4 +1,4 @@
class Internal::AccountAnalysis::ContentEvaluatorService < Llm::BaseOpenAiService
class Internal::AccountAnalysis::ContentEvaluatorService < Llm::LegacyBaseOpenAiService
def initialize
super()
@@ -0,0 +1,33 @@
# frozen_string_literal: true
# Base service for LLM operations using RubyLLM.
# New features should inherit from this class.
class Llm::BaseAiService
DEFAULT_MODEL = Llm::Config::DEFAULT_MODEL
DEFAULT_TEMPERATURE = 1.0
attr_reader :model, :temperature
def initialize
Llm::Config.initialize!
setup_model
setup_temperature
end
# Returns a configured RubyLLM chat instance.
# Subclasses can override model/temperature via instance variables or pass them explicitly.
def chat(model: @model, temperature: @temperature)
RubyLLM.chat(model: model).with_temperature(temperature)
end
private
def setup_model
config_value = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
@model = (config_value.presence || DEFAULT_MODEL)
end
def setup_temperature
@temperature = DEFAULT_TEMPERATURE
end
end
@@ -1,5 +1,11 @@
class Llm::BaseOpenAiService
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
# frozen_string_literal: true
# DEPRECATED: This class uses the legacy OpenAI Ruby gem directly.
# New features should use Llm::BaseAiService with RubyLLM instead.
# This class will be removed once all services are migrated to RubyLLM.
class Llm::LegacyBaseOpenAiService
DEFAULT_MODEL = 'gpt-4o-mini'
attr_reader :client, :model
def initialize
@@ -1,4 +1,4 @@
class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService
attr_reader :attachment, :message, :account
def initialize(attachment)
@@ -27,10 +27,16 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
end
def fetch_audio_file
temp_dir = Rails.root.join('tmp/uploads')
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
FileUtils.mkdir_p(temp_dir)
temp_file_path = File.join(temp_dir, attachment.file.filename.to_s)
File.write(temp_file_path, attachment.file.download, mode: 'wb')
temp_file_path = File.join(temp_dir, "#{attachment.file.blob.key}-#{attachment.file.filename}")
File.open(temp_file_path, 'wb') do |file|
attachment.file.blob.open do |blob_file|
IO.copy_stream(blob_file, file)
end
end
temp_file_path
end
@@ -40,18 +46,24 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
temp_file_path = fetch_audio_file
response = @client.audio.transcribe(
parameters: {
model: 'whisper-1',
file: File.open(temp_file_path),
temperature: 0.4
}
)
response_text = nil
File.open(temp_file_path, 'rb') do |file|
response = @client.audio.transcribe(
parameters: {
model: 'whisper-1',
file: file,
temperature: 0.4
}
)
response_text = response['text']
end
FileUtils.rm_f(temp_file_path)
update_transcription(response['text'])
response['text']
update_transcription(response_text)
response_text
end
def update_transcription(transcribed_text)
+1 -1
View File
@@ -5,7 +5,7 @@ class ChatwootMarkdownRenderer
def render_message
markdown_renderer = BaseMarkdownRenderer.new
doc = CommonMarker.render_doc(@content, :DEFAULT)
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
html = markdown_renderer.render(doc)
render_as_html_safe(html)
end
+1 -1
View File
@@ -164,6 +164,6 @@ class Integrations::LlmBaseService
end
def build_error_response_from_exception(error, messages)
{ error: error.message, error_code: 500, request_messages: messages }
{ error: error.message, request_messages: messages }
end
end
+8 -20
View File
@@ -1,12 +1,11 @@
# frozen_string_literal: true
require 'opentelemetry_config'
require_relative 'llm_instrumentation_constants'
require_relative 'llm_instrumentation_helpers'
module Integrations::LlmInstrumentation
include Integrations::LlmInstrumentationConstants
include Integrations::LlmInstrumentationHelpers
include Integrations::LlmInstrumentationSpans
PROVIDER_PREFIXES = {
'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-],
@@ -16,10 +15,6 @@ module Integrations::LlmInstrumentation
'deepseek' => %w[deepseek-]
}.freeze
def tracer
@tracer ||= OpentelemetryConfig.tracer
end
def instrument_llm_call(params)
return yield unless ChatwootApp.otel_enabled?
@@ -43,6 +38,7 @@ module Integrations::LlmInstrumentation
result = nil
executed = false
tracer.in_span(params[:span_name]) do |span|
set_request_attributes(span, params)
set_metadata_attributes(span, params)
# By default, the input and output of a trace are set from the root observation
@@ -98,7 +94,12 @@ module Integrations::LlmInstrumentation
end
def record_completion(span, result)
set_completion_attributes(span, result) if result.is_a?(Hash)
if result.respond_to?(:content)
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, result.role.to_s) if result.respond_to?(:role)
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result.content.to_s)
elsif result.is_a?(Hash)
set_completion_attributes(span, result) if result.is_a?(Hash)
end
end
def set_request_attributes(span, params)
@@ -117,17 +118,4 @@ module Integrations::LlmInstrumentation
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
end
end
def set_metadata_attributes(span, params)
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present?
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
return unless params[:metadata].is_a?(Hash)
params[:metadata].each do |key, value|
span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s)
end
end
end
@@ -32,9 +32,20 @@ module Integrations::LlmInstrumentationHelpers
error = result[:error] || result['error']
return if error.blank?
error_code = result[:error_code] || result['error_code']
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code
span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}")
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
end
def set_metadata_attributes(span, params)
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present?
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
return unless params[:metadata].is_a?(Hash)
params[:metadata].each do |key, value|
span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s)
end
end
end
@@ -0,0 +1,92 @@
# frozen_string_literal: true
require 'opentelemetry_config'
require_relative 'llm_instrumentation_constants'
module Integrations::LlmInstrumentationSpans
include Integrations::LlmInstrumentationConstants
def tracer
@tracer ||= OpentelemetryConfig.tracer
end
def start_llm_turn_span(params)
return unless ChatwootApp.otel_enabled?
span = tracer.start_span(params[:span_name])
set_llm_turn_request_attributes(span, params)
set_llm_turn_prompt_attributes(span, params[:messages]) if params[:messages]
@pending_llm_turn_spans ||= []
@pending_llm_turn_spans.push(span)
rescue StandardError => e
Rails.logger.warn "Failed to start LLM turn span: #{e.message}"
end
def end_llm_turn_span(message)
return unless ChatwootApp.otel_enabled?
span = @pending_llm_turn_spans&.pop
return unless span
set_llm_turn_response_attributes(span, message) if message
span.finish
rescue StandardError => e
Rails.logger.warn "Failed to end LLM turn span: #{e.message}"
end
def start_tool_span(tool_call)
return unless ChatwootApp.otel_enabled?
tool_name = tool_call.name.to_s
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
@pending_tool_spans ||= []
@pending_tool_spans.push(span)
rescue StandardError => e
Rails.logger.warn "Failed to start tool span: #{e.message}"
end
def end_tool_span(result)
return unless ChatwootApp.otel_enabled?
span = @pending_tool_spans&.pop
return unless span
output = result.is_a?(String) ? result : result.to_json
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, output)
span.finish
rescue StandardError => e
Rails.logger.warn "Failed to end tool span: #{e.message}"
end
private
def set_llm_turn_request_attributes(span, params)
provider = determine_provider(params[:model])
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model]) if params[:model]
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
end
def set_llm_turn_prompt_attributes(span, messages)
messages.each_with_index do |msg, idx|
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), msg[:role])
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), msg[:content])
end
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, messages.to_json)
end
def set_llm_turn_response_attributes(span, message)
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, message.role.to_s) if message.respond_to?(:role)
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.content.to_s) if message.respond_to?(:content)
set_llm_turn_usage_attributes(span, message)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content)
end
def set_llm_turn_usage_attributes(span, message)
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens) if message.respond_to?(:input_tokens) && message.input_tokens
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens) && message.output_tokens
end
end
@@ -121,8 +121,6 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
end
def upload_files
return unless message.attachments.any?
files = build_files_array
return if files.empty?
@@ -136,6 +134,8 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
Rails.logger.info "slack_upload_result: #{result}"
rescue Slack::Web::Api::Errors::SlackError => e
Rails.logger.error "Failed to upload files: #{e.message}"
ensure
files.each { |file| file[:content]&.clear }
end
end
@@ -143,14 +143,31 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
message.attachments.filter_map do |attachment|
next unless attachment.with_attached_file?
{
filename: attachment.file.filename.to_s,
content: attachment.file.download,
title: attachment.file.filename.to_s
}
build_file_payload(attachment)
end
end
def build_file_payload(attachment)
content = download_attachment_content(attachment)
return if content.blank?
{
filename: attachment.file.filename.to_s,
content: content,
title: attachment.file.filename.to_s
}
end
def download_attachment_content(attachment)
buffer = +''
attachment.file.blob.open do |file|
while (chunk = file.read(64.kilobytes))
buffer << chunk
end
end
buffer
end
def sender_name(sender)
sender.try(:name) ? "#{sender.try(:name)} (#{sender_type(sender)})" : sender_type(sender)
end
+1
View File
@@ -33,6 +33,7 @@ module Llm::Config
RubyLLM.configure do |config|
config.openai_api_key = system_api_key if system_api_key.present?
config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present?
config.logger = Rails.logger
end
end
+5 -4
View File
@@ -2,11 +2,14 @@
# NOTE: are sensitive to local FS writes, and besides -- it's just not proper
# NOTE: to have a dev-mode tool do its thing in production.
if Rails.env.development?
require 'annotate'
require 'annotate_rb'
AnnotateRb::Core.load_rake_tasks
task :set_annotation_options do
# You can override any of these by setting an environment variable of the
# same name.
Annotate.set_defaults(
AnnotateRb::Options.set_defaults(
'additional_file_patterns' => [],
'routes' => 'false',
'models' => 'true',
@@ -55,6 +58,4 @@ if Rails.env.development?
'with_comment' => 'true'
)
end
Annotate.load_tasks
end
+5 -5
View File
@@ -33,7 +33,7 @@
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.2.3",
"@chatwoot/prosemirror-schema": "1.2.5",
"@chatwoot/utils": "^0.0.51",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
@@ -45,7 +45,7 @@
"@rails/actioncable": "6.1.3",
"@rails/ujs": "^7.1.400",
"@scmmishra/pico-search": "0.5.4",
"@sentry/vue": "^8.31.0",
"@sentry/vue": "^8.55.0",
"@sindresorhus/slugify": "2.2.1",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/vue-table": "^8.20.5",
@@ -56,7 +56,7 @@
"@vueuse/components": "^12.0.0",
"@vueuse/core": "^12.0.0",
"activestorage": "^5.2.6",
"axios": "^1.8.2",
"axios": "^1.13.2",
"camelcase-keys": "^9.1.3",
"chart.js": "~4.4.4",
"color2k": "^2.0.2",
@@ -132,8 +132,8 @@
"fake-indexeddb": "^6.0.0",
"histoire": "0.17.15",
"husky": "^7.0.0",
"jsdom": "^24.1.3",
"lint-staged": "14.0.1",
"jsdom": "^27.2.0",
"lint-staged": "^16.2.7",
"postcss": "^8.4.47",
"postcss-preset-env": "^8.5.1",
"prettier": "^3.3.3",
+425 -411
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
require 'rubocop'
module RuboCop::Cop::Chatwoot; end
class RuboCop::Cop::Chatwoot::AttachmentDownload < RuboCop::Cop::Base
MSG = 'Avoid calling `.file/.blob.download`; use `blob.open` or streaming IO instead.'.freeze
def_node_matcher :unsafe_download?, <<~PATTERN
(send (send _ {:file :blob}) :download ...)
PATTERN
def on_send(node)
return unless unsafe_download?(node)
add_offense(node.loc.selector, message: MSG)
end
end
@@ -245,6 +245,78 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
end
end
describe 'POST /enterprise/api/v1/accounts/{account.id}/topup_checkout' do
let(:stripe_customer_id) { 'cus_test123' }
let(:invoice_settings) { Struct.new(:default_payment_method).new('pm_test123') }
let(:stripe_customer) { Struct.new(:invoice_settings, :default_source).new(invoice_settings, nil) }
let(:stripe_invoice) { Struct.new(:id).new('inv_test123') }
before do
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
])
end
it 'returns unauthorized for unauthenticated user' do
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout", as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns unauthorized for agent' do
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
headers: agent.create_new_auth_token,
params: { credits: 1000 },
as: :json
expect(response).to have_http_status(:unauthorized)
end
context 'when it is an admin' do
before do
account.update!(
custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id },
limits: { 'captain_responses' => 1000 }
)
allow(Stripe::Customer).to receive(:retrieve).with(stripe_customer_id).and_return(stripe_customer)
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
allow(Stripe::InvoiceItem).to receive(:create)
allow(Stripe::Invoice).to receive(:finalize_invoice)
allow(Stripe::Invoice).to receive(:pay)
allow(Stripe::Billing::CreditGrant).to receive(:create)
end
it 'successfully processes topup and returns correct response' do
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
headers: admin.create_new_auth_token,
params: { credits: 1000 },
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['credits']).to eq(1000)
expect(json_response['amount']).to eq(20.0)
expect(json_response['limits']['captain_responses']).to eq(2000)
end
it 'returns error when credits parameter is missing' do
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns error for invalid credits amount' do
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
headers: admin.create_new_auth_token,
params: { credits: 999 },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'POST /enterprise/api/v1/accounts/{account.id}/toggle_deletion' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -7,7 +7,6 @@ RSpec.describe Captain::Copilot::ChatService do
let(:assistant) { create(:captain_assistant, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let(:mock_openai_client) { instance_double(OpenAI::Client) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
let!(:copilot_message) do
create(
@@ -20,13 +19,29 @@ RSpec.describe Captain::Copilot::ChatService do
{ user_id: user.id, copilot_thread_id: copilot_thread.id, conversation_id: conversation.display_id }
end
# RubyLLM mocks
let(:mock_chat) { instance_double(RubyLLM::Chat) }
let(:mock_response) do
instance_double(RubyLLM::Message, content: '{ "content": "Hey", "reasoning": "Test reasoning", "reply_suggestion": false }')
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://api.openai.com/')
allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
allow(mock_openai_client).to receive(:chat).and_return({
choices: [{ message: { content: '{ "content": "Hey" }' } }]
}.with_indifferent_access)
InstallationConfig.find_or_create_by(name: 'CAPTAIN_OPEN_AI_API_KEY') do |c|
c.value = 'test-key'
end
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
allow(mock_chat).to receive(:with_params).and_return(mock_chat)
allow(mock_chat).to receive(:with_tool).and_return(mock_chat)
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
allow(mock_chat).to receive(:add_message).and_return(mock_chat)
allow(mock_chat).to receive(:on_new_message).and_return(mock_chat)
allow(mock_chat).to receive(:on_end_message).and_return(mock_chat)
allow(mock_chat).to receive(:on_tool_call).and_return(mock_chat)
allow(mock_chat).to receive(:on_tool_result).and_return(mock_chat)
allow(mock_chat).to receive(:messages).and_return([])
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
describe '#initialize' do
@@ -48,48 +63,6 @@ RSpec.describe Captain::Copilot::ChatService do
expect(messages.second[:role]).to eq('system')
expect(messages.second[:content]).to include(account.id.to_s)
end
it 'initializes OpenAI client with configured endpoint' do
expect(OpenAI::Client).to receive(:new).with(
access_token: 'test-key',
uri_base: 'https://api.openai.com/',
log_errors: Rails.env.development?
)
described_class.new(assistant, config)
end
context 'when CAPTAIN_OPEN_AI_ENDPOINT is not configured' do
before do
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy
end
it 'uses default OpenAI endpoint' do
expect(OpenAI::Client).to receive(:new).with(
access_token: 'test-key',
uri_base: 'https://api.openai.com/',
log_errors: Rails.env.development?
)
described_class.new(assistant, config)
end
end
context 'when custom endpoint is configured' do
before do
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT').update!(value: 'https://custom.azure.com/')
end
it 'uses custom endpoint for OpenAI client' do
expect(OpenAI::Client).to receive(:new).with(
access_token: 'test-key',
uri_base: 'https://custom.azure.com/',
log_errors: Rails.env.development?
)
described_class.new(assistant, config)
end
end
end
describe '#generate_response' do
@@ -112,82 +85,19 @@ RSpec.describe Captain::Copilot::ChatService do
end
it 'returns the response from request_chat_completion' do
expect(service.generate_response('Hello')).to eq({ 'content' => 'Hey' })
result = service.generate_response('Hello')
expect(result).to eq({ 'content' => 'Hey', 'reasoning' => 'Test reasoning', 'reply_suggestion' => false })
end
context 'when response contains tool calls' do
before do
allow(mock_openai_client).to receive(:chat).and_return(
{
choices: [{ message: { 'tool_calls' => tool_calls } }]
}.with_indifferent_access,
{
choices: [{ message: { content: '{ "content": "Tool response processed" }' } }]
}.with_indifferent_access
)
end
context 'when tool call is valid' do
let(:tool_calls) do
[{
'id' => 'call_123',
'function' => {
'name' => 'get_conversation',
'arguments' => "{ \"conversation_id\": #{conversation.display_id} }"
}
}]
end
it 'processes tool calls and appends them to messages' do
result = service.generate_response("Find conversation #{conversation.id}")
expect(result).to eq({ 'content' => 'Tool response processed' })
expect(service.messages).to include(
{ role: 'assistant', tool_calls: tool_calls }
)
expect(service.messages).to include(
{
role: 'tool', tool_call_id: 'call_123', content: conversation.to_llm_text
}
)
expect(result).to eq({ 'content' => 'Tool response processed' })
end
end
context 'when tool call is invalid' do
let(:tool_calls) do
[{
'id' => 'call_123',
'function' => {
'name' => 'get_settings',
'arguments' => '{}'
}
}]
end
it 'handles invalid tool calls' do
result = service.generate_response('Find settings')
expect(result).to eq({ 'content' => 'Tool response processed' })
expect(service.messages).to include(
{
role: 'assistant', tool_calls: tool_calls
}
)
expect(service.messages).to include(
{
role: 'tool',
tool_call_id: 'call_123',
content: 'Tool not available'
}
)
end
end
it 'increments response usage for the account' do
expect do
service.generate_response('Hello')
end.to(change { account.reload.custom_attributes['captain_responses_usage'].to_i }.by(1))
end
end
describe '#setup_user' do
describe 'user setup behavior' do
it 'sets user when user_id is present in config' do
service = described_class.new(assistant, { user_id: user.id })
expect(service.user).to eq(user)
@@ -199,7 +109,7 @@ RSpec.describe Captain::Copilot::ChatService do
end
end
describe '#setup_message_history' do
describe 'message history behavior' do
context 'when copilot_thread_id is present' do
it 'finds the copilot thread and sets previous history from it' do
service = described_class.new(assistant, { copilot_thread_id: copilot_thread.id })
@@ -227,7 +137,7 @@ RSpec.describe Captain::Copilot::ChatService do
end
end
describe '#build_messages' do
describe 'message building behavior' do
it 'includes system message and account context' do
service = described_class.new(assistant, {})
messages = service.messages
@@ -257,13 +167,9 @@ RSpec.describe Captain::Copilot::ChatService do
end
end
describe '#persist_message' do
describe 'message persistence behavior' do
context 'when copilot_thread is present' do
it 'creates a copilot message' do
allow(mock_openai_client).to receive(:chat).and_return({
choices: [{ message: { content: '{ "content": "Hey" }' } }]
}.with_indifferent_access)
it 'creates a copilot message with the response' do
expect do
described_class.new(assistant, { copilot_thread_id: copilot_thread.id }).generate_response('Hello')
end.to change(CopilotMessage, :count).by(1)
@@ -276,10 +182,6 @@ RSpec.describe Captain::Copilot::ChatService do
context 'when copilot_thread is not present' do
it 'does not create a copilot message' do
allow(mock_openai_client).to receive(:chat).and_return({
choices: [{ message: { content: '{ "content": "Hey" }' } }]
}.with_indifferent_access)
expect do
described_class.new(assistant, {}).generate_response('Hello')
end.not_to(change(CopilotMessage, :count))
@@ -28,13 +28,14 @@ RSpec.describe Captain::Llm::PdfProcessingService do
context 'when uploading PDF to OpenAI' do
let(:mock_client) { instance_double(OpenAI::Client) }
let(:pdf_content) { 'PDF content' }
let(:blob_double) { instance_double(ActiveStorage::Blob) }
let(:pdf_file) { instance_double(ActiveStorage::Attachment) }
before do
allow(document).to receive(:openai_file_id).and_return(nil)
# Use a simple double for ActiveStorage since it's a complex Rails object
pdf_file = double('pdf_file', download: pdf_content) # rubocop:disable RSpec/VerifiedDoubles
allow(document).to receive(:pdf_file).and_return(pdf_file)
allow(pdf_file).to receive(:blob).and_return(blob_double)
allow(blob_double).to receive(:open).and_yield(StringIO.new(pdf_content))
allow(OpenAI::Client).to receive(:new).and_return(mock_client)
# Use a simple double for OpenAI::Files as it may not be loaded
@@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
article_id: {
type: 'number',
description: 'The ID of the article to retrieve'
}
},
required: %w[article_id]
}
)
it 'defines article_id parameter' do
expect(service.parameters.keys).to contain_exactly(:article_id)
end
end
@@ -79,13 +68,13 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do
describe '#execute' do
context 'when article_id is blank' do
it 'returns error message' do
expect(service.execute({})).to eq('Missing required parameters')
expect(service.execute(article_id: nil)).to eq('Article not found')
end
end
context 'when article is not found' do
it 'returns not found message' do
expect(service.execute({ 'article_id' => 999 })).to eq('Article not found')
expect(service.execute(article_id: 999)).to eq('Article not found')
end
end
@@ -94,7 +83,7 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do
let(:article) { create(:article, account: account, portal: portal, author: user, title: 'Test Article', content: 'Content') }
it 'returns the article in llm text format' do
result = service.execute({ 'article_id' => article.id })
result = service.execute(article_id: article.id)
expect(result).to eq(article.to_llm_text)
end
@@ -104,7 +93,7 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do
let(:other_article) { create(:article, account: other_account, portal: other_portal, author: user, title: 'Other Article') }
it 'returns not found message' do
expect(service.execute({ 'article_id' => other_article.id })).to eq('Article not found')
expect(service.execute(article_id: other_article.id)).to eq('Article not found')
end
end
end
@@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
contact_id: {
type: 'number',
description: 'The ID of the contact to retrieve'
}
},
required: %w[contact_id]
}
)
it 'defines contact_id parameter' do
expect(service.parameters.keys).to contain_exactly(:contact_id)
end
end
@@ -78,14 +67,14 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do
describe '#execute' do
context 'when contact_id is blank' do
it 'returns error message' do
expect(service.execute({})).to eq('Missing required parameters')
it 'returns not found message' do
expect(service.execute(contact_id: nil)).to eq('Contact not found')
end
end
context 'when contact is not found' do
it 'returns not found message' do
expect(service.execute({ 'contact_id' => 999 })).to eq('Contact not found')
expect(service.execute(contact_id: 999)).to eq('Contact not found')
end
end
@@ -93,7 +82,7 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do
let(:contact) { create(:contact, account: account) }
it 'returns the contact in llm text format' do
result = service.execute({ 'contact_id' => contact.id })
result = service.execute(contact_id: contact.id)
expect(result).to eq(contact.to_llm_text)
end
@@ -102,7 +91,7 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do
let(:other_contact) { create(:contact, account: other_account) }
it 'returns not found message' do
expect(service.execute({ 'contact_id' => other_contact.id })).to eq('Contact not found')
expect(service.execute(contact_id: other_contact.id)).to eq('Contact not found')
end
end
end
@@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
conversation_id: {
type: 'number',
description: 'The ID of the conversation to retrieve'
}
},
required: %w[conversation_id]
}
)
it 'defines conversation_id parameter' do
expect(service.parameters.keys).to contain_exactly(:conversation_id)
end
end
@@ -107,15 +96,9 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
end
describe '#execute' do
context 'when conversation_id is blank' do
it 'returns error message' do
expect(service.execute({})).to eq('Missing required parameters')
end
end
context 'when conversation is not found' do
it 'returns not found message' do
expect(service.execute({ 'conversation_id' => 999 })).to eq('Conversation not found')
expect(service.execute(conversation_id: 999)).to eq('Conversation not found')
end
end
@@ -124,7 +107,7 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
it 'returns the conversation in llm text format' do
result = service.execute({ 'conversation_id' => conversation.display_id })
result = service.execute(conversation_id: conversation.display_id)
expect(result).to eq(conversation.to_llm_text)
end
@@ -143,7 +126,7 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
content: 'Private note content',
private: true)
result = service.execute({ 'conversation_id' => conversation.display_id })
result = service.execute(conversation_id: conversation.display_id)
# Verify that the result includes both regular and private messages
expect(result).to include('Regular message')
@@ -157,7 +140,7 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
let(:other_conversation) { create(:conversation, account: other_account, inbox: other_inbox) }
it 'returns not found message' do
expect(service.execute({ 'conversation_id' => other_conversation.display_id })).to eq('Conversation not found')
expect(service.execute(conversation_id: other_conversation.display_id)).to eq('Conversation not found')
end
end
end
@@ -18,32 +18,6 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
end
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search articles by title or content (partial match)'
},
category_id: {
type: 'number',
description: 'Filter articles by category ID'
},
status: {
type: 'string',
enum: %w[draft published archived],
description: 'Filter articles by status'
}
},
required: ['query']
}
)
end
end
describe '#active?' do
context 'when user is an admin' do
let(:user) { create(:user, :administrator, account: account) }
@@ -95,15 +69,9 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
end
describe '#execute' do
context 'when query is blank' do
it 'returns error message' do
expect(service.execute({})).to eq('Missing required parameters')
end
end
context 'when no articles are found' do
it 'returns no articles found message' do
expect(service.execute({ 'query' => 'test' })).to eq('No articles found')
expect(service.execute(query: 'test', category_id: nil, status: nil)).to eq('No articles found')
end
end
@@ -113,7 +81,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
let!(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') }
it 'returns formatted articles with count' do
result = service.execute({ 'query' => 'Test' })
result = service.execute(query: 'Test', category_id: nil, status: nil)
expect(result).to include('Total number of articles: 2')
expect(result).to include(article1.to_llm_text)
expect(result).to include(article2.to_llm_text)
@@ -124,7 +92,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
let!(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') }
it 'returns only articles from the specified category' do
result = service.execute({ 'query' => 'Test', 'category_id' => category.id })
result = service.execute(query: 'Test', category_id: category.id, status: nil)
expect(result).to include('Total number of articles: 1')
expect(result).to include(article3.to_llm_text)
expect(result).not_to include(article1.to_llm_text)
@@ -137,7 +105,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
let!(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') }
it 'returns only articles with the specified status' do
result = service.execute({ 'query' => 'Test', 'status' => 'published' })
result = service.execute(query: 'Test', category_id: nil, status: 'published')
expect(result).to include(article3.to_llm_text)
expect(result).not_to include(article4.to_llm_text)
end
@@ -19,27 +19,8 @@ RSpec.describe Captain::Tools::Copilot::SearchContactsService do
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
email: {
type: 'string',
description: 'Filter contacts by email'
},
phone_number: {
type: 'string',
description: 'Filter contacts by phone number'
},
name: {
type: 'string',
description: 'Filter contacts by name (partial match)'
}
},
required: []
}
)
it 'defines email, phone_number, and name parameters' do
expect(service.parameters.keys).to contain_exactly(:email, :phone_number, :name)
end
end
@@ -86,25 +67,25 @@ RSpec.describe Captain::Tools::Copilot::SearchContactsService do
end
it 'returns contacts when filtered by email' do
result = service.execute({ 'email' => 'test1@example.com' })
result = service.execute(email: 'test1@example.com')
expect(result).to include(contact1.to_llm_text)
expect(result).not_to include(contact2.to_llm_text)
end
it 'returns contacts when filtered by phone number' do
result = service.execute({ 'phone_number' => '+1234567890' })
result = service.execute(phone_number: '+1234567890')
expect(result).to include(contact1.to_llm_text)
expect(result).not_to include(contact2.to_llm_text)
end
it 'returns contacts when filtered by name' do
result = service.execute({ 'name' => 'Contact 1' })
result = service.execute(name: 'Contact 1')
expect(result).to include(contact1.to_llm_text)
expect(result).not_to include(contact2.to_llm_text)
end
it 'returns all matching contacts when no filters are provided' do
result = service.execute({})
result = service.execute
expect(result).to include(contact1.to_llm_text)
expect(result).to include(contact2.to_llm_text)
end
@@ -8,7 +8,7 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('search_conversations')
expect(service.name).to eq('search_conversation')
end
end
@@ -19,10 +19,8 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
end
describe '#parameters' do
it 'returns the correct parameter schema' do
params = service.parameters
expect(params[:type]).to eq('object')
expect(params[:properties]).to include(:contact_id, :status, :priority)
it 'defines the expected parameters' do
expect(service.parameters.keys).to contain_exactly(:status, :contact_id, :priority, :labels)
end
end
@@ -90,35 +88,35 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
let!(:resolved_conversation) { create(:conversation, account: account, status: 'resolved', priority: 'low') }
it 'returns all conversations when no filters are applied' do
result = service.execute({})
result = service.execute
expect(result).to include('Total number of conversations: 2')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by status' do
result = service.execute({ 'status' => 'open' })
result = service.execute(status: 'open')
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by contact_id' do
result = service.execute({ 'contact_id' => contact.id })
result = service.execute(contact_id: contact.id)
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by priority' do
result = service.execute({ 'priority' => 'high' })
result = service.execute(priority: 'high')
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'returns appropriate message when no conversations are found' do
result = service.execute({ 'status' => 'snoozed' })
result = service.execute(status: 'snoozed')
expect(result).to eq('No conversations found')
end
end
@@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
term: {
type: 'string',
description: 'The search term to find Linear issues'
}
},
required: %w[term]
}
)
it 'defines term parameter' do
expect(service.parameters.keys).to contain_exactly(:term)
end
end
@@ -76,7 +65,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
describe '#execute' do
context 'when Linear integration is not enabled' do
it 'returns error message' do
expect(service.execute({ 'term' => 'test' })).to eq('Linear integration is not enabled')
expect(service.execute(term: 'test')).to eq('Linear integration is not enabled')
end
end
@@ -89,8 +78,12 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
end
context 'when term is blank' do
it 'returns error message' do
expect(service.execute({ 'term' => '' })).to eq('Missing required parameters')
before do
allow(linear_service).to receive(:search_issue).with('').and_return({ data: [] })
end
it 'returns no issues found message' do
expect(service.execute(term: '')).to eq('No issues found, I should try another similar search term')
end
end
@@ -100,7 +93,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
end
it 'returns the error message' do
expect(service.execute({ 'term' => 'test' })).to eq('API Error')
expect(service.execute(term: 'test')).to eq('API Error')
end
end
@@ -110,7 +103,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
end
it 'returns no issues found message' do
expect(service.execute({ 'term' => 'test' })).to eq('No issues found, I should try another similar search term')
expect(service.execute(term: 'test')).to eq('No issues found, I should try another similar search term')
end
end
@@ -131,7 +124,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
end
it 'returns formatted issues' do
result = service.execute({ 'term' => 'test' })
result = service.execute(term: 'test')
expect(result).to include('Total number of issues: 1')
expect(result).to include('Title: Test Issue')
expect(result).to include('ID: TEST-123')
@@ -20,19 +20,8 @@ RSpec.describe Captain::Tools::SearchDocumentationService do
end
describe '#parameters' do
it 'returns the required parameters schema' do
expected_schema = {
type: 'object',
properties: {
search_query: {
type: 'string',
description: 'The search query to look up in the documentation.'
}
},
required: ['search_query']
}
expect(service.parameters).to eq(expected_schema)
it 'defines query parameter' do
expect(service.parameters.keys).to contain_exactly(:query)
end
end
@@ -56,7 +45,7 @@ RSpec.describe Captain::Tools::SearchDocumentationService do
end
it 'returns formatted responses for the search query' do
result = service.execute({ 'search_query' => question })
result = service.execute(query: question)
expect(result).to include(question)
expect(result).to include(answer)
@@ -70,7 +59,7 @@ RSpec.describe Captain::Tools::SearchDocumentationService do
end
it 'returns an empty string' do
expect(service.execute({ 'search_query' => question })).to eq('No FAQs found for the given query')
expect(service.execute(query: question)).to eq('No FAQs found for the given query')
end
end
end
@@ -32,6 +32,7 @@ describe Enterprise::Billing::HandleStripeEventService do
# Setup common subscription mocks
allow(event).to receive(:data).and_return(data)
allow(data).to receive(:object).and_return(subscription)
allow(data).to receive(:previous_attributes).and_return({})
allow(subscription).to receive(:[]).with('quantity').and_return('10')
allow(subscription).to receive(:[]).with('status').and_return('active')
allow(subscription).to receive(:[]).with('current_period_end').and_return(1_686_567_520)
@@ -62,7 +63,7 @@ describe Enterprise::Billing::HandleStripeEventService do
expect(account).not_to be_feature_enabled('audit_logs')
end
it 'resets captain usage on subscription update' do
it 'resets captain usage on billing period renewal' do
# Prime the account with some usage
5.times { account.increment_response_usage }
expect(account.custom_attributes['captain_responses_usage']).to eq(5)
@@ -70,6 +71,10 @@ describe Enterprise::Billing::HandleStripeEventService do
# Setup for any plan
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
allow(subscription).to receive(:[]).with('current_period_start').and_return(1_686_567_520)
# Simulate billing period renewal with previous_attributes showing old period
allow(data).to receive(:previous_attributes).and_return({ 'current_period_start' => 1_683_975_520 })
stripe_event_service.new.perform(event: event)
@@ -0,0 +1,60 @@
require 'rails_helper'
describe Enterprise::Billing::TopupCheckoutService do
subject(:service) { described_class.new(account: account) }
let(:account) { create(:account) }
let(:stripe_customer_id) { 'cus_test123' }
let(:invoice_settings) { Struct.new(:default_payment_method).new('pm_test') }
let(:stripe_customer) { Struct.new(:invoice_settings, :default_source).new(invoice_settings, nil) }
let(:stripe_invoice) { Struct.new(:id).new('inv_test123') }
before do
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
])
account.update!(
custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id },
limits: { 'captain_responses' => 500 }
)
allow(Stripe::Customer).to receive(:retrieve).and_return(stripe_customer)
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
allow(Stripe::InvoiceItem).to receive(:create)
allow(Stripe::Invoice).to receive(:finalize_invoice)
allow(Stripe::Invoice).to receive(:pay)
allow(Stripe::Billing::CreditGrant).to receive(:create)
end
describe '#create_checkout_session' do
it 'successfully processes topup and returns correct response' do
result = service.create_checkout_session(credits: 1000)
expect(result[:credits]).to eq(1000)
expect(result[:amount]).to eq(20.0)
expect(result[:currency]).to eq('usd')
end
it 'updates account limits after successful topup' do
service.create_checkout_session(credits: 1000)
expect(account.reload.limits['captain_responses']).to eq(1500)
end
it 'raises error for invalid credits' do
expect do
service.create_checkout_session(credits: 500)
end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error)
end
it 'raises error when account is on free plan' do
account.update!(custom_attributes: { plan_name: 'Hacker', stripe_customer_id: stripe_customer_id })
expect do
service.create_checkout_session(credits: 1000)
end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error)
end
end
end
@@ -0,0 +1,36 @@
require 'rails_helper'
describe Enterprise::Billing::TopupFulfillmentService do
subject(:service) { described_class.new(account: account) }
let(:account) { create(:account) }
let(:stripe_customer_id) { 'cus_test123' }
before do
account.update!(
custom_attributes: { stripe_customer_id: stripe_customer_id },
limits: { 'captain_responses' => 1000 }
)
allow(Stripe::Billing::CreditGrant).to receive(:create)
end
describe '#fulfill' do
it 'adds credits to account limits' do
service.fulfill(credits: 1000, amount_cents: 2000, currency: 'usd')
expect(account.reload.limits['captain_responses']).to eq(2000)
end
it 'creates a Stripe credit grant' do
service.fulfill(credits: 1000, amount_cents: 2000, currency: 'usd')
expect(Stripe::Billing::CreditGrant).to have_received(:create).with(
hash_including(
customer: stripe_customer_id,
name: 'Topup: 1000 credits',
category: 'paid'
)
)
end
end
end
+7 -2
View File
@@ -15,8 +15,11 @@ RSpec.describe DataImportJob do
describe 'retrying the job' do
context 'when ActiveStorage::FileNotFoundError is raised' do
let(:import_file_double) { instance_double(ActiveStorage::Blob) }
before do
allow(data_import.import_file).to receive(:download).and_raise(ActiveStorage::FileNotFoundError)
allow(data_import).to receive(:import_file).and_return(import_file_double)
allow(import_file_double).to receive(:open).and_raise(ActiveStorage::FileNotFoundError)
end
it 'retries the job' do
@@ -158,7 +161,9 @@ RSpec.describe DataImportJob do
end
before do
allow(data_import.import_file).to receive(:download).and_return(invalid_csv_content)
import_file_double = instance_double(ActiveStorage::Blob)
allow(data_import).to receive(:import_file).and_return(import_file_double)
allow(import_file_double).to receive(:open).and_yield(StringIO.new(invalid_csv_content))
end
it 'does not import any data and handles the MalformedCSVError' do
+2 -1
View File
@@ -10,7 +10,7 @@ RSpec.describe ChatwootMarkdownRenderer do
let(:html_content) { '<p>This is a <em>test</em> content with <sup>markdown</sup></p>' }
before do
allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT).and_return(doc)
allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT, [:strikethrough]).and_return(doc)
allow(CustomMarkdownRenderer).to receive(:new).and_return(markdown_renderer)
allow(markdown_renderer).to receive(:render).with(doc).and_return(html_content)
end
@@ -86,6 +86,7 @@ RSpec.describe ChatwootMarkdownRenderer do
let(:rendered_content) { renderer.render_markdown_to_plain_text }
before do
allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT).and_return(doc)
allow(doc).to receive(:to_plaintext).and_return(plain_text_content)
end
@@ -200,17 +200,15 @@ RSpec.describe Integrations::LlmInstrumentation do
result = instance.instrument_llm_call(params) do
{
error: { message: 'API rate limit exceeded' },
error_code: 'rate_limit_exceeded'
error: 'API rate limit exceeded'
}
end
expect(result[:error_code]).to eq('rate_limit_exceeded')
expect(result[:error]).to eq('API rate limit exceeded')
expect(mock_span).to have_received(:set_attribute)
.with('gen_ai.response.error', '{"message":"API rate limit exceeded"}')
expect(mock_span).to have_received(:set_attribute).with('gen_ai.response.error_code', 'rate_limit_exceeded')
.with('gen_ai.response.error', '"API rate limit exceeded"')
expect(mock_span).to have_received(:status=).with(mock_status)
expect(OpenTelemetry::Trace::Status).to have_received(:error).with('API Error: rate_limit_exceeded')
expect(OpenTelemetry::Trace::Status).to have_received(:error).with('API rate limit exceeded')
end
end
@@ -256,52 +254,5 @@ RSpec.describe Integrations::LlmInstrumentation do
end
end
end
describe '#instrument_tool_call' do
let(:tool_name) { 'search_documents' }
let(:arguments) { { query: 'test query' } }
context 'when OTEL provider is not configured' do
before { otel_config.update(value: '') }
it 'executes the block without tracing' do
result = instance.instrument_tool_call(tool_name, arguments) { 'tool_result' }
expect(result).to eq('tool_result')
end
end
context 'when OTEL provider is configured' do
let(:mock_span) { instance_double(OpenTelemetry::Trace::Span) }
let(:mock_tracer) { instance_double(OpenTelemetry::Trace::Tracer) }
before do
allow(mock_span).to receive(:set_attribute)
allow(instance).to receive(:tracer).and_return(mock_tracer)
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
end
it 'executes the block and returns the result' do
result = instance.instrument_tool_call(tool_name, arguments) { 'tool_result' }
expect(result).to eq('tool_result')
end
it 'propagates instrumentation errors' do
allow(mock_tracer).to receive(:in_span).and_raise(StandardError.new('Instrumentation failed'))
expect do
instance.instrument_tool_call(tool_name, arguments) { 'tool_result' }
end.to raise_error(StandardError, 'Instrumentation failed')
end
it 'creates a span with tool name and sets observation attributes' do
tool_result = { documents: ['doc1'] }
instance.instrument_tool_call(tool_name, arguments) { tool_result }
expect(mock_tracer).to have_received(:in_span).with('tool.search_documents')
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.input', arguments.to_json)
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', tool_result.to_json)
end
end
end
end
end

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