Merge branch 'develop' into fix/idb-cache-cleanup
This commit is contained in:
@@ -4,6 +4,11 @@
|
||||
|
||||
- **Setup**: `bundle install && pnpm install`
|
||||
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
|
||||
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
|
||||
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
|
||||
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
|
||||
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
|
||||
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
|
||||
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
|
||||
- **Lint Ruby**: `bundle exec rubocop -a`
|
||||
- **Test JS**: `pnpm test` or `pnpm test:watch`
|
||||
@@ -93,3 +98,7 @@ Practical checklist for any change impacting core logic or public APIs
|
||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
|
||||
|
||||
## Branding / White-labeling note
|
||||
|
||||
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
|
||||
|
||||
@@ -191,7 +191,7 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents', '>= 0.7.0'
|
||||
gem 'ai-agents'
|
||||
|
||||
# TODO: Move this gem as a dependency of ai-agents
|
||||
gem 'ruby_llm', '>= 1.8.2'
|
||||
|
||||
+8
-8
@@ -126,8 +126,8 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.7.0)
|
||||
ruby_llm (~> 1.8.2)
|
||||
ai-agents (0.9.0)
|
||||
ruby_llm (~> 1.9.1)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
@@ -314,7 +314,7 @@ GEM
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
faraday-retry (2.2.1)
|
||||
faraday-retry (2.4.0)
|
||||
faraday (~> 2.0)
|
||||
faraday_middleware-aws-sigv4 (1.0.1)
|
||||
aws-sigv4 (~> 1.0)
|
||||
@@ -540,7 +540,7 @@ GEM
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
marcel (1.1.0)
|
||||
maxminddb (0.1.22)
|
||||
meta_request (0.8.5)
|
||||
rack-contrib (>= 1.1, < 3)
|
||||
@@ -559,7 +559,7 @@ GEM
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.8.0)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multipart-post (2.3.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
@@ -825,7 +825,7 @@ GEM
|
||||
ruby2ruby (2.5.0)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_llm (1.8.2)
|
||||
ruby_llm (1.9.2)
|
||||
base64
|
||||
event_stream_parser (~> 1)
|
||||
faraday (>= 1.10.0)
|
||||
@@ -1004,7 +1004,7 @@ GEM
|
||||
working_hours (1.4.1)
|
||||
activesupport (>= 3.2)
|
||||
tzinfo
|
||||
zeitwerk (2.6.17)
|
||||
zeitwerk (2.7.4)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
@@ -1024,7 +1024,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.7.0)
|
||||
ai-agents
|
||||
annotaterb
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
|
||||
@@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||
|
||||
def create
|
||||
@user = User.from_email(params[:email])
|
||||
if @user
|
||||
@user.send_reset_password_instructions
|
||||
build_response(I18n.t('messages.reset_password_success'), 200)
|
||||
else
|
||||
build_response(I18n.t('messages.reset_password_failure'), 404)
|
||||
end
|
||||
@user&.send_reset_password_instructions
|
||||
build_response(I18n.t('messages.reset_password'), 200)
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -43,6 +43,7 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue';
|
||||
|
||||
import MessageError from './MessageError.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Attachment
|
||||
@@ -143,6 +144,7 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
@@ -389,13 +391,17 @@ const shouldRenderMessage = computed(() => {
|
||||
const isUnsupported = props.contentAttributes?.isUnsupported;
|
||||
const isAnIntegrationMessage =
|
||||
props.contentType === CONTENT_TYPES.INTEGRATIONS;
|
||||
const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
|
||||
const hasExternalError = !!props.contentAttributes?.externalError;
|
||||
|
||||
return (
|
||||
hasAttachments ||
|
||||
props.content ||
|
||||
isEmailContentType ||
|
||||
isUnsupported ||
|
||||
isAnIntegrationMessage
|
||||
isAnIntegrationMessage ||
|
||||
isFailedMessage ||
|
||||
hasExternalError
|
||||
);
|
||||
});
|
||||
|
||||
@@ -472,7 +478,7 @@ const avatarInfo = computed(() => {
|
||||
|
||||
const avatarTooltip = computed(() => {
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
return t('CONVERSATION.NATIVE_APP_ADVISORY');
|
||||
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
|
||||
}
|
||||
if (avatarInfo.value.name === '') return '';
|
||||
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
||||
|
||||
@@ -12,11 +12,16 @@ defineProps({
|
||||
|
||||
const emit = defineEmits(['retry']);
|
||||
|
||||
const { orientation, status, createdAt } = useMessageContext();
|
||||
const { orientation, status, createdAt, content, attachments } =
|
||||
useMessageContext();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
|
||||
const canRetry = computed(() => {
|
||||
const hasContent = content.value !== null;
|
||||
const hasAttachments = attachments.value && attachments.value.length > 0;
|
||||
return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -145,6 +145,7 @@ const {
|
||||
isConversationSelected,
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onRemoveLabels,
|
||||
onAssignTeamsForBulk,
|
||||
onUpdateConversations,
|
||||
} = useBulkActions();
|
||||
@@ -859,6 +860,7 @@ provide('deSelectConversation', deSelectConversation);
|
||||
provide('assignAgent', onAssignAgent);
|
||||
provide('assignTeam', onAssignTeam);
|
||||
provide('assignLabels', onAssignLabels);
|
||||
provide('removeLabels', onRemoveLabels);
|
||||
provide('updateConversationStatus', handleResolveConversation);
|
||||
provide('toggleContextMenu', onContextMenuToggle);
|
||||
provide('markAsUnread', markAsUnread);
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
'assignAgent',
|
||||
'assignTeam',
|
||||
'assignLabels',
|
||||
'removeLabels',
|
||||
'updateConversationStatus',
|
||||
'toggleContextMenu',
|
||||
'markAsUnread',
|
||||
@@ -63,6 +64,7 @@ export default {
|
||||
@assign-agent="assignAgent"
|
||||
@assign-team="assignTeam"
|
||||
@assign-label="assignLabels"
|
||||
@remove-label="removeLabels"
|
||||
@update-conversation-status="updateConversationStatus"
|
||||
@context-menu-toggle="toggleContextMenu"
|
||||
@mark-as-unread="markAsUnread"
|
||||
|
||||
@@ -28,7 +28,10 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import {
|
||||
CONVERSATION_EVENTS,
|
||||
CAPTAIN_EVENTS,
|
||||
} from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
||||
|
||||
import {
|
||||
@@ -86,6 +89,7 @@ const props = defineProps({
|
||||
// are triggered except when this flag is true
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
conversationId: { type: Number, default: null },
|
||||
medium: { type: String, default: '' },
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
focusOnMount: { type: Boolean, default: true },
|
||||
@@ -396,7 +400,14 @@ function openFileBrowser() {
|
||||
}
|
||||
|
||||
function handleCopilotClick() {
|
||||
showSelectionMenu.value = !showSelectionMenu.value;
|
||||
const isOpening = !showSelectionMenu.value;
|
||||
if (isOpening) {
|
||||
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
|
||||
conversationId: props.conversationId,
|
||||
entryPoint: 'inline',
|
||||
});
|
||||
}
|
||||
showSelectionMenu.value = isOpening;
|
||||
}
|
||||
|
||||
function handleClickOutside(event) {
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import { ref } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import EditorModeToggle from './EditorModeToggle.vue';
|
||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||
@@ -31,6 +33,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
isMessageLengthReachingThreshold: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
@@ -69,7 +75,14 @@ export default {
|
||||
};
|
||||
|
||||
const toggleCopilotMenu = () => {
|
||||
showCopilotMenu.value = !showCopilotMenu.value;
|
||||
const isOpening = !showCopilotMenu.value;
|
||||
if (isOpening) {
|
||||
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
|
||||
conversationId: props.conversationId,
|
||||
entryPoint: 'top_panel',
|
||||
});
|
||||
}
|
||||
showCopilotMenu.value = isOpening;
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ const emit = defineEmits([
|
||||
'contextMenuToggle',
|
||||
'assignAgent',
|
||||
'assignLabel',
|
||||
'removeLabel',
|
||||
'assignTeam',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
@@ -203,7 +204,10 @@ const onAssignAgent = agent => {
|
||||
|
||||
const onAssignLabel = label => {
|
||||
emit('assignLabel', [label.title], [props.chat.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onRemoveLabel = label => {
|
||||
emit('removeLabel', [label.title], [props.chat.id]);
|
||||
};
|
||||
|
||||
const onAssignTeam = team => {
|
||||
@@ -379,11 +383,13 @@ const deleteConversation = () => {
|
||||
:priority="chat.priority"
|
||||
:chat-id="chat.id"
|
||||
:has-unread-messages="hasUnread"
|
||||
:conversation-labels="chat.labels"
|
||||
:conversation-url="conversationPath"
|
||||
:allowed-options="allowedContextMenuOptions"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@remove-label="onRemoveLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
|
||||
@@ -41,7 +41,10 @@ import {
|
||||
truncatePreviewText,
|
||||
appendQuotedTextToMessage,
|
||||
} from 'dashboard/helper/quotedEmailHelper';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import {
|
||||
CONVERSATION_EVENTS,
|
||||
CAPTAIN_EVENTS,
|
||||
} from '../../../helper/AnalyticsHelper/events';
|
||||
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import {
|
||||
appendSignature,
|
||||
@@ -136,6 +139,7 @@ export default {
|
||||
newConversationModalActive: false,
|
||||
showArticleSearchPopover: false,
|
||||
hasRecordedAudio: false,
|
||||
copilotAcceptedMessages: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -508,6 +512,24 @@ export default {
|
||||
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
|
||||
},
|
||||
methods: {
|
||||
getDraftKey(
|
||||
conversationId = this.conversationIdByRoute,
|
||||
replyType = this.replyType
|
||||
) {
|
||||
return `draft-${conversationId}-${replyType}`;
|
||||
},
|
||||
getCopilotAcceptedMessage(replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
return this.copilotAcceptedMessages[key] || '';
|
||||
},
|
||||
setCopilotAcceptedMessage(message, replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
this.copilotAcceptedMessages[key] = trimContent(message || '');
|
||||
},
|
||||
clearCopilotAcceptedMessage(replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
delete this.copilotAcceptedMessages[key];
|
||||
},
|
||||
handleInsert(article) {
|
||||
const { url, title } = article;
|
||||
// Removing empty lines from the title
|
||||
@@ -559,7 +581,7 @@ export default {
|
||||
},
|
||||
saveDraft(conversationId, replyType) {
|
||||
if (this.message || this.message === '') {
|
||||
const key = `draft-${conversationId}-${replyType}`;
|
||||
const key = this.getDraftKey(conversationId, replyType);
|
||||
const draftToSave = trimContent(this.message || '');
|
||||
|
||||
this.$store.dispatch('draftMessages/set', {
|
||||
@@ -574,7 +596,7 @@ export default {
|
||||
},
|
||||
getFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
const key = this.getDraftKey();
|
||||
const messageFromStore =
|
||||
this.$store.getters['draftMessages/get'](key) || '';
|
||||
|
||||
@@ -597,7 +619,7 @@ export default {
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
const key = this.getDraftKey();
|
||||
this.$store.dispatch('draftMessages/delete', { key });
|
||||
}
|
||||
},
|
||||
@@ -708,6 +730,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
if (!this.showMentions) {
|
||||
const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
|
||||
const isOnWhatsApp =
|
||||
this.isATwilioWhatsAppChannel ||
|
||||
this.isAWhatsAppCloudChannel ||
|
||||
@@ -717,10 +740,17 @@ export default {
|
||||
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
|
||||
const isOnInstagram = this.isAnInstagramChannel;
|
||||
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
||||
this.sendMessageAsMultipleMessages(this.message);
|
||||
this.sendMessageAsMultipleMessages(
|
||||
this.message,
|
||||
copilotAcceptedMessage
|
||||
);
|
||||
} else {
|
||||
const messagePayload = this.getMessagePayload(this.message);
|
||||
this.sendMessage(messagePayload);
|
||||
this.sendMessage(
|
||||
messagePayload,
|
||||
this.message,
|
||||
copilotAcceptedMessage
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.isPrivate) {
|
||||
@@ -732,13 +762,53 @@ export default {
|
||||
this.$emit('update:popOutReplyBox', false);
|
||||
}
|
||||
},
|
||||
sendMessageAsMultipleMessages(message) {
|
||||
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
|
||||
const messages = this.getMultipleMessagesPayload(message);
|
||||
messages.forEach(messagePayload => {
|
||||
this.sendMessage(messagePayload);
|
||||
this.sendMessage(
|
||||
messagePayload,
|
||||
messagePayload.message || '',
|
||||
copilotAcceptedMessage
|
||||
);
|
||||
});
|
||||
},
|
||||
sendMessageAnalyticsData(isPrivate) {
|
||||
sendMessageAnalyticsData(
|
||||
isPrivate,
|
||||
{ editorMessage = '', copilotAcceptedMessage = '' } = {}
|
||||
) {
|
||||
const normalizeForComparison = message => {
|
||||
let normalizedMessage = message || '';
|
||||
|
||||
if (this.sendWithSignature && this.messageSignature && !isPrivate) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
normalizedMessage = removeSignature(
|
||||
normalizedMessage,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
|
||||
return trimContent(normalizedMessage);
|
||||
};
|
||||
|
||||
const normalizedAcceptedMessage = normalizeForComparison(
|
||||
copilotAcceptedMessage
|
||||
);
|
||||
const normalizedEditorMessage = normalizeForComparison(editorMessage);
|
||||
|
||||
if (normalizedAcceptedMessage && normalizedEditorMessage) {
|
||||
useTrack(CAPTAIN_EVENTS.AI_ASSISTED_MESSAGE_SENT, {
|
||||
conversationId: this.conversationIdByRoute,
|
||||
channelType: this.channelType,
|
||||
editedBeforeSend:
|
||||
normalizedAcceptedMessage !== normalizedEditorMessage,
|
||||
isPrivate,
|
||||
});
|
||||
}
|
||||
|
||||
// Analytics data for message signature is enabled or not in channels
|
||||
return isPrivate
|
||||
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
|
||||
@@ -772,7 +842,11 @@ export default {
|
||||
this.confirmOnSendReply();
|
||||
}
|
||||
},
|
||||
async sendMessage(messagePayload) {
|
||||
async sendMessage(
|
||||
messagePayload,
|
||||
editorMessage = '',
|
||||
copilotAcceptedMessage = ''
|
||||
) {
|
||||
try {
|
||||
await this.$store.dispatch(
|
||||
'createPendingMessageAndSend',
|
||||
@@ -781,7 +855,10 @@ export default {
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
|
||||
this.removeFromDraft();
|
||||
this.sendMessageAnalyticsData(messagePayload.private);
|
||||
this.sendMessageAnalyticsData(messagePayload.private, {
|
||||
editorMessage,
|
||||
copilotAcceptedMessage,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
|
||||
@@ -855,6 +932,7 @@ export default {
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
this.clearCopilotAcceptedMessage();
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// if signature is enabled, append it to the message
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
@@ -1119,7 +1197,9 @@ export default {
|
||||
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
|
||||
},
|
||||
onSubmitCopilotReply() {
|
||||
this.message = this.copilot.accept();
|
||||
const acceptedMessage = this.copilot.accept();
|
||||
this.message = acceptedMessage;
|
||||
this.setCopilotAcceptedMessage(acceptedMessage);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1130,6 +1210,7 @@ export default {
|
||||
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
:conversation-id="conversationId"
|
||||
:is-reply-restricted="isReplyRestricted"
|
||||
:disabled="
|
||||
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
|
||||
@@ -1204,6 +1285,7 @@ export default {
|
||||
<WootMessageEditor
|
||||
v-else-if="!showAudioRecorderEditor"
|
||||
v-model="message"
|
||||
:conversation-id="conversationId"
|
||||
:editor-id="editorStateId"
|
||||
class="input popover-prosemirror-menu"
|
||||
:is-private="isOnPrivateNote"
|
||||
|
||||
@@ -53,6 +53,10 @@ export default {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
conversationLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
conversationUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
@@ -70,6 +74,7 @@ export default {
|
||||
'assignAgent',
|
||||
'assignTeam',
|
||||
'assignLabel',
|
||||
'removeLabel',
|
||||
'deleteConversation',
|
||||
'close',
|
||||
],
|
||||
@@ -334,8 +339,16 @@ export default {
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
variant="label"
|
||||
@click.stop="$emit('assignLabel', label)"
|
||||
:variant="
|
||||
conversationLabels.includes(label.title)
|
||||
? 'label-assigned'
|
||||
: 'label'
|
||||
"
|
||||
@click.stop="
|
||||
conversationLabels.includes(label.title)
|
||||
? $emit('removeLabel', label)
|
||||
: $emit('assignLabel', label)
|
||||
"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
option: {
|
||||
@@ -22,7 +23,9 @@ defineProps({
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
v-if="variant === 'label' && option.color"
|
||||
v-if="
|
||||
(variant === 'label' || variant === 'label-assigned') && option.color
|
||||
"
|
||||
class="label-pill flex-shrink-0"
|
||||
:style="{ backgroundColor: option.color }"
|
||||
/>
|
||||
@@ -37,6 +40,11 @@ defineProps({
|
||||
<p class="menu-label truncate min-w-0 flex-1">
|
||||
{{ option.label }}
|
||||
</p>
|
||||
<Icon
|
||||
v-if="variant === 'label-assigned'"
|
||||
icon="i-lucide-check"
|
||||
class="flex-shrink-0 size-3.5 mr-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+6
-2
@@ -2,6 +2,7 @@
|
||||
// components
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
// composables
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
@@ -34,8 +35,9 @@ export default {
|
||||
},
|
||||
setup() {
|
||||
const { captainTasksEnabled } = useCaptain();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
return { captainTasksEnabled };
|
||||
return { captainTasksEnabled, replaceInstallationName };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -228,7 +230,9 @@ export default {
|
||||
<div class="sender--info has-tooltip" data-original-title="null">
|
||||
<Avatar
|
||||
v-tooltip.top="{
|
||||
content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
|
||||
content: replaceInstallationName(
|
||||
$t('LABEL_MGMT.SUGGESTIONS.POWERED_BY')
|
||||
),
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const CAPTAIN_ERROR_TYPES = Object.freeze({
|
||||
ABORTED: 'aborted',
|
||||
API_ERROR: 'api_error',
|
||||
HTTP_PREFIX: 'http_',
|
||||
ABORT_ERROR: 'AbortError',
|
||||
CANCELED_ERROR: 'CanceledError',
|
||||
});
|
||||
|
||||
export const CAPTAIN_GENERATION_FAILURE_REASONS = Object.freeze({
|
||||
EMPTY_RESPONSE: 'empty_response',
|
||||
EXCEPTION: 'exception',
|
||||
});
|
||||
@@ -102,6 +102,28 @@ export function useBulkActions() {
|
||||
}
|
||||
}
|
||||
|
||||
// Only used in context menu
|
||||
async function onRemoveLabels(labelsToRemove, conversationId = null) {
|
||||
try {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: conversationId || selectedConversations.value,
|
||||
labels: {
|
||||
remove: labelsToRemove,
|
||||
},
|
||||
});
|
||||
|
||||
useAlert(
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
|
||||
labelName: labelsToRemove[0],
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
|
||||
}
|
||||
}
|
||||
|
||||
async function onAssignTeamsForBulk(team) {
|
||||
try {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
@@ -189,6 +211,7 @@ export function useBulkActions() {
|
||||
isConversationSelected,
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onRemoveLabels,
|
||||
onAssignTeamsForBulk,
|
||||
onUpdateConversations,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
import { CAPTAIN_ERROR_TYPES } from 'dashboard/composables/captain/constants';
|
||||
|
||||
export function useCaptain() {
|
||||
const store = useStore();
|
||||
@@ -69,7 +70,10 @@ export function useCaptain() {
|
||||
* @param {Error} error - The error object from the API call.
|
||||
*/
|
||||
const handleAPIError = error => {
|
||||
if (error.name === 'AbortError' || error.name === 'CanceledError') {
|
||||
if (
|
||||
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const errorMessage =
|
||||
@@ -78,6 +82,24 @@ export function useCaptain() {
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Classifies API error types for downstream analytics.
|
||||
* @param {Error} error
|
||||
* @returns {string}
|
||||
*/
|
||||
const getErrorType = error => {
|
||||
if (
|
||||
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||
) {
|
||||
return CAPTAIN_ERROR_TYPES.ABORTED;
|
||||
}
|
||||
if (error.response?.status) {
|
||||
return `${CAPTAIN_ERROR_TYPES.HTTP_PREFIX}${error.response.status}`;
|
||||
}
|
||||
return CAPTAIN_ERROR_TYPES.API_ERROR;
|
||||
};
|
||||
|
||||
// === Task Methods ===
|
||||
/**
|
||||
* Rewrites content with a specific operation.
|
||||
@@ -103,7 +125,7 @@ export function useCaptain() {
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '' };
|
||||
return { message: '', errorType: getErrorType(error) };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,7 +147,7 @@ export function useCaptain() {
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '' };
|
||||
return { message: '', errorType: getErrorType(error) };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,7 +169,7 @@ export function useCaptain() {
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '' };
|
||||
return { message: '', errorType: getErrorType(error) };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -171,7 +193,11 @@ export function useCaptain() {
|
||||
return { message: generatedMessage, followUpContext: updatedContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '', followUpContext };
|
||||
return {
|
||||
message: '',
|
||||
followUpContext,
|
||||
errorType: getErrorType(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import {
|
||||
CAPTAIN_ERROR_TYPES,
|
||||
CAPTAIN_GENERATION_FAILURE_REASONS,
|
||||
} from 'dashboard/composables/captain/constants';
|
||||
|
||||
// Actions that map to REWRITE events (with operation attribute)
|
||||
const REWRITE_ACTIONS = [
|
||||
@@ -52,6 +56,20 @@ function buildPayload(action, conversationId, followUpCount = undefined) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function trackGenerationFailure({
|
||||
action,
|
||||
conversationId,
|
||||
followUpCount = undefined,
|
||||
stage,
|
||||
reason,
|
||||
}) {
|
||||
useTrack(CAPTAIN_EVENTS.GENERATION_FAILED, {
|
||||
...buildPayload(action, conversationId, followUpCount),
|
||||
stage,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing Copilot reply generation state and actions.
|
||||
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
|
||||
@@ -146,7 +164,8 @@ export function useCopilotReply() {
|
||||
|
||||
// Reset without tracking dismiss (starting new action)
|
||||
reset(false);
|
||||
abortController.value = new AbortController();
|
||||
const requestController = new AbortController();
|
||||
abortController.value = requestController;
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
currentAction.value = action;
|
||||
@@ -154,28 +173,66 @@ export function useCopilotReply() {
|
||||
trackedConversationId.value = conversationId.value;
|
||||
|
||||
try {
|
||||
const { message: content, followUpContext: newContext } =
|
||||
await processEvent(action, data, {
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
const {
|
||||
message: content,
|
||||
followUpContext: newContext,
|
||||
errorType,
|
||||
} = await processEvent(action, data, {
|
||||
signal: requestController.signal,
|
||||
});
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = newContext;
|
||||
if (content) {
|
||||
showEditor.value = true;
|
||||
// Track "Used" event on successful generation
|
||||
const eventKey = `${getEventPrefix(action)}_USED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(action, trackedConversationId.value)
|
||||
);
|
||||
if (requestController.signal.aborted) return;
|
||||
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||
if (abortController.value === requestController) {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
isGenerating.value = false;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
isGenerating.value = false;
|
||||
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = newContext;
|
||||
if (content) {
|
||||
showEditor.value = true;
|
||||
// Track "Used" event on successful generation
|
||||
const eventKey = `${getEventPrefix(action)}_USED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(action, trackedConversationId.value)
|
||||
);
|
||||
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||
trackGenerationFailure({
|
||||
action,
|
||||
conversationId: trackedConversationId.value,
|
||||
stage: 'initial',
|
||||
reason: errorType,
|
||||
});
|
||||
} else {
|
||||
trackGenerationFailure({
|
||||
action,
|
||||
conversationId: trackedConversationId.value,
|
||||
stage: 'initial',
|
||||
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
|
||||
});
|
||||
}
|
||||
isGenerating.value = false;
|
||||
} catch (error) {
|
||||
if (
|
||||
requestController.signal.aborted ||
|
||||
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||
) {
|
||||
return;
|
||||
}
|
||||
trackGenerationFailure({
|
||||
action,
|
||||
conversationId: trackedConversationId.value,
|
||||
stage: 'initial',
|
||||
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
|
||||
});
|
||||
isGenerating.value = false;
|
||||
} finally {
|
||||
if (abortController.value === requestController) {
|
||||
abortController.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,7 +244,8 @@ export function useCopilotReply() {
|
||||
async function sendFollowUp(message) {
|
||||
if (!followUpContext.value || !message.trim()) return;
|
||||
|
||||
abortController.value = new AbortController();
|
||||
const requestController = new AbortController();
|
||||
abortController.value = requestController;
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
|
||||
@@ -198,24 +256,65 @@ export function useCopilotReply() {
|
||||
followUpCount.value += 1;
|
||||
|
||||
try {
|
||||
const { message: content, followUpContext: updatedContext } =
|
||||
await followUp({
|
||||
followUpContext: followUpContext.value,
|
||||
message,
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
const {
|
||||
message: content,
|
||||
followUpContext: updatedContext,
|
||||
errorType,
|
||||
} = await followUp({
|
||||
followUpContext: followUpContext.value,
|
||||
message,
|
||||
signal: requestController.signal,
|
||||
});
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
if (content) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = updatedContext;
|
||||
showEditor.value = true;
|
||||
if (requestController.signal.aborted) return;
|
||||
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||
if (abortController.value === requestController) {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
isGenerating.value = false;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
isGenerating.value = false;
|
||||
|
||||
if (content) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = updatedContext;
|
||||
showEditor.value = true;
|
||||
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||
trackGenerationFailure({
|
||||
action: currentAction.value,
|
||||
conversationId: trackedConversationId.value,
|
||||
followUpCount: followUpCount.value,
|
||||
stage: 'follow_up',
|
||||
reason: errorType,
|
||||
});
|
||||
} else {
|
||||
trackGenerationFailure({
|
||||
action: currentAction.value,
|
||||
conversationId: trackedConversationId.value,
|
||||
followUpCount: followUpCount.value,
|
||||
stage: 'follow_up',
|
||||
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
|
||||
});
|
||||
}
|
||||
isGenerating.value = false;
|
||||
} catch (error) {
|
||||
if (
|
||||
requestController.signal.aborted ||
|
||||
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||
) {
|
||||
return;
|
||||
}
|
||||
trackGenerationFailure({
|
||||
action: currentAction.value,
|
||||
conversationId: trackedConversationId.value,
|
||||
followUpCount: followUpCount.value,
|
||||
stage: 'follow_up',
|
||||
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
|
||||
});
|
||||
isGenerating.value = false;
|
||||
} finally {
|
||||
if (abortController.value === requestController) {
|
||||
abortController.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,11 @@ export const PORTALS_EVENTS = Object.freeze({
|
||||
});
|
||||
|
||||
export const CAPTAIN_EVENTS = Object.freeze({
|
||||
// Editor funnel events
|
||||
EDITOR_AI_MENU_OPENED: 'Captain: Editor AI menu opened',
|
||||
GENERATION_FAILED: 'Captain: Generation failed',
|
||||
AI_ASSISTED_MESSAGE_SENT: 'Captain: AI-assisted message sent',
|
||||
|
||||
// Rewrite events (with operation attribute in payload)
|
||||
REWRITE_USED: 'Captain: Rewrite used',
|
||||
REWRITE_APPLIED: 'Captain: Rewrite applied',
|
||||
|
||||
@@ -174,6 +174,10 @@
|
||||
"SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}",
|
||||
"FAILED": "Couldn't assign label. Please try again."
|
||||
},
|
||||
"LABEL_REMOVAL": {
|
||||
"SUCCESFUL": "Removed label #{labelName} from conversation id {conversationId}",
|
||||
"FAILED": "Couldn't remove label. Please try again."
|
||||
},
|
||||
"TEAM_ASSIGNMENT": {
|
||||
"SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}",
|
||||
"FAILED": "Couldn't assign team. Please try again."
|
||||
|
||||
@@ -19,10 +19,18 @@ module AutoAssignmentHandler
|
||||
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
|
||||
else
|
||||
# Use legacy assignment system
|
||||
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
|
||||
# If conversation has a team, only consider team members for assignment
|
||||
allowed_agent_ids = team_id.present? ? team_member_ids_with_capacity : inbox.member_ids_with_assignment_capacity
|
||||
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: allowed_agent_ids).perform
|
||||
end
|
||||
end
|
||||
|
||||
def team_member_ids_with_capacity
|
||||
return [] if team.blank? || team.allow_auto_assign.blank?
|
||||
|
||||
inbox.member_ids_with_assignment_capacity & team.members.ids
|
||||
end
|
||||
|
||||
def should_run_auto_assignment?
|
||||
return false unless inbox.enable_auto_assignment?
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class AutoAssignment::AssignmentService
|
||||
def perform_for_conversation(conversation)
|
||||
return false unless assignable?(conversation)
|
||||
|
||||
agent = find_available_agent
|
||||
agent = find_available_agent(conversation)
|
||||
return false unless agent
|
||||
|
||||
assign_conversation(conversation, agent)
|
||||
@@ -44,13 +44,26 @@ class AutoAssignment::AssignmentService
|
||||
scope.limit(limit)
|
||||
end
|
||||
|
||||
def find_available_agent
|
||||
agents = filter_agents_by_rate_limit(inbox.available_agents)
|
||||
def find_available_agent(conversation = nil)
|
||||
agents = filter_agents_by_team(inbox.available_agents, conversation)
|
||||
return nil if agents.nil?
|
||||
|
||||
agents = filter_agents_by_rate_limit(agents)
|
||||
return nil if agents.empty?
|
||||
|
||||
round_robin_selector.select_agent(agents)
|
||||
end
|
||||
|
||||
def filter_agents_by_team(agents, conversation)
|
||||
return agents if conversation&.team_id.blank?
|
||||
|
||||
team = conversation.team
|
||||
return nil if team.blank? || team.allow_auto_assign.blank?
|
||||
|
||||
team_member_ids = team.members.ids
|
||||
agents.where(user_id: team_member_ids)
|
||||
end
|
||||
|
||||
def filter_agents_by_rate_limit(agents)
|
||||
agents.select do |agent_member|
|
||||
rate_limiter = build_rate_limiter(agent_member.user)
|
||||
|
||||
@@ -47,8 +47,10 @@ class Twilio::DeliveryStatusService
|
||||
@twilio_channel ||= if params[:MessagingServiceSid].present?
|
||||
::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid])
|
||||
elsif params[:AccountSid].present? && params[:From].present?
|
||||
::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid], phone_number: params[:From])
|
||||
::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: params[:From])
|
||||
end
|
||||
log_channel_not_found if @twilio_channel.blank?
|
||||
@twilio_channel
|
||||
end
|
||||
|
||||
def message
|
||||
@@ -56,4 +58,14 @@ class Twilio::DeliveryStatusService
|
||||
|
||||
@message ||= twilio_channel.inbox.messages.find_by(source_id: params[:MessageSid])
|
||||
end
|
||||
|
||||
def log_channel_not_found
|
||||
Rails.logger.warn(
|
||||
'[TWILIO] Delivery status channel lookup failed ' \
|
||||
"account_sid=#{params[:AccountSid]} " \
|
||||
"from=#{params[:From]} " \
|
||||
"messaging_service_sid=#{params[:MessagingServiceSid]} " \
|
||||
"message_sid=#{params[:MessageSid]}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,12 +26,23 @@ class Twilio::IncomingMessageService
|
||||
def twilio_channel
|
||||
@twilio_channel ||= ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) if params[:MessagingServiceSid].present?
|
||||
if params[:AccountSid].present? && params[:To].present?
|
||||
@twilio_channel ||= ::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid],
|
||||
phone_number: params[:To])
|
||||
@twilio_channel ||= ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid],
|
||||
phone_number: params[:To])
|
||||
end
|
||||
log_channel_not_found if @twilio_channel.blank?
|
||||
@twilio_channel
|
||||
end
|
||||
|
||||
def log_channel_not_found
|
||||
Rails.logger.warn(
|
||||
'[TWILIO] Incoming message channel lookup failed ' \
|
||||
"account_sid=#{params[:AccountSid]} " \
|
||||
"to=#{params[:To]} " \
|
||||
"messaging_service_sid=#{params[:MessagingServiceSid]} " \
|
||||
"sms_sid=#{params[:SmsSid]}"
|
||||
)
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= twilio_channel.inbox
|
||||
end
|
||||
|
||||
@@ -41,8 +41,7 @@ en:
|
||||
invalid_email: 'Please enter a valid email address'
|
||||
authentication_failed: 'Authentication failed. Please check your credentials and try again.'
|
||||
messages:
|
||||
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
|
||||
reset_password_failure: Uh ho! We could not find any user with the specified email.
|
||||
reset_password: Request for password reset is successful. A email with instructions will be sent to your email if it exists.
|
||||
reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
|
||||
login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
|
||||
saml_not_available: SAML authentication is not available in this installation.
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
module Captain::ChatResponseHelper
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
|
||||
private
|
||||
|
||||
def build_response(response)
|
||||
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
|
||||
|
||||
parsed = parse_json_response(response.content)
|
||||
apply_credit_usage_metadata(parsed)
|
||||
|
||||
persist_message(parsed, 'assistant')
|
||||
parsed
|
||||
@@ -19,6 +22,26 @@ module Captain::ChatResponseHelper
|
||||
{ 'content' => content }
|
||||
end
|
||||
|
||||
def apply_credit_usage_metadata(parsed_response)
|
||||
return unless captain_v1_assistant?
|
||||
|
||||
OpenTelemetry::Trace.current_span.set_attribute(
|
||||
format(ATTR_LANGFUSE_METADATA, 'credit_used'),
|
||||
credit_used_for_response?(parsed_response).to_s
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "#{self.class.name} Assistant: #{@assistant.id}, Failed to set credit usage metadata: #{e.message}"
|
||||
end
|
||||
|
||||
def credit_used_for_response?(parsed_response)
|
||||
response = parsed_response['response']
|
||||
response.present? && response != 'conversation_handoff'
|
||||
end
|
||||
|
||||
def captain_v1_assistant?
|
||||
feature_name == 'assistant' && !@assistant.account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
|
||||
def persist_thinking_message(tool_call)
|
||||
return if @copilot_thread.blank?
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
class Messages::AudioTranscriptionJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
discard_on Faraday::BadRequestError do |job, error|
|
||||
log_context = {
|
||||
attachment_id: job.arguments.first,
|
||||
job_id: job.job_id,
|
||||
status_code: error.response&.dig(:status)
|
||||
}
|
||||
|
||||
Rails.logger.warn("Discarding audio transcription job due to bad request: #{log_context}")
|
||||
end
|
||||
retry_on ActiveStorage::FileNotFoundError, wait: 2.seconds, attempts: 3
|
||||
|
||||
def perform(attachment_id)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
require 'agents'
|
||||
require 'agents/instrumentation'
|
||||
|
||||
class Captain::Assistant::AgentRunnerService
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
label_list custom_attributes additional_attributes
|
||||
@@ -22,7 +25,9 @@ class Captain::Assistant::AgentRunnerService
|
||||
context = build_context(message_history)
|
||||
message_to_process = extract_last_user_message(message_history)
|
||||
runner = Agents::Runner.with_agents(*agents)
|
||||
runner = add_usage_metadata_callback(runner)
|
||||
runner = add_callbacks_to_runner(runner) if @callbacks.any?
|
||||
install_instrumentation(runner)
|
||||
result = runner.run(message_to_process, context: context, max_turns: 100)
|
||||
|
||||
process_agent_result(result)
|
||||
@@ -50,6 +55,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
end
|
||||
|
||||
{
|
||||
session_id: "#{@assistant.account_id}_#{@conversation&.display_id}",
|
||||
conversation_history: conversation_history,
|
||||
state: build_state
|
||||
}
|
||||
@@ -124,6 +130,31 @@ class Captain::Assistant::AgentRunnerService
|
||||
[assistant_agent] + scenario_agents
|
||||
end
|
||||
|
||||
def install_instrumentation(runner)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
|
||||
Agents::Instrumentation.install(
|
||||
runner,
|
||||
tracer: OpentelemetryConfig.tracer,
|
||||
trace_name: 'llm.captain_v2',
|
||||
span_attributes: {
|
||||
ATTR_LANGFUSE_TAGS => ['captain_v2'].to_json
|
||||
},
|
||||
attribute_provider: ->(context_wrapper) { dynamic_trace_attributes(context_wrapper) }
|
||||
)
|
||||
end
|
||||
|
||||
def dynamic_trace_attributes(context_wrapper)
|
||||
state = context_wrapper&.context&.dig(:state) || {}
|
||||
conversation = state[:conversation] || {}
|
||||
{
|
||||
ATTR_LANGFUSE_USER_ID => state[:account_id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'assistant_id') => state[:assistant_id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id]
|
||||
}.compact.transform_values(&:to_s)
|
||||
end
|
||||
|
||||
def add_callbacks_to_runner(runner)
|
||||
runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking]
|
||||
runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start]
|
||||
@@ -132,6 +163,36 @@ class Captain::Assistant::AgentRunnerService
|
||||
runner
|
||||
end
|
||||
|
||||
def add_usage_metadata_callback(runner)
|
||||
return runner unless ChatwootApp.otel_enabled?
|
||||
|
||||
handoff_tool_name = Captain::Tools::HandoffTool.new(@assistant).name
|
||||
|
||||
runner.on_tool_complete do |tool_name, _tool_result, context_wrapper|
|
||||
track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
|
||||
end
|
||||
|
||||
runner.on_run_complete do |_agent_name, _result, context_wrapper|
|
||||
write_credits_used_metadata(context_wrapper)
|
||||
end
|
||||
runner
|
||||
end
|
||||
|
||||
def track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
|
||||
return unless context_wrapper&.context
|
||||
return unless tool_name.to_s == handoff_tool_name
|
||||
|
||||
context_wrapper.context[:captain_v2_handoff_tool_called] = true
|
||||
end
|
||||
|
||||
def write_credits_used_metadata(context_wrapper)
|
||||
root_span = context_wrapper&.context&.dig(:__otel_tracing, :root_span)
|
||||
return unless root_span
|
||||
|
||||
credits_used = !context_wrapper.context[:captain_v2_handoff_tool_called]
|
||||
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credits_used'), credits_used)
|
||||
end
|
||||
|
||||
def add_agent_thinking_callback(runner)
|
||||
runner.on_agent_thinking do |*args|
|
||||
@callbacks[:on_agent_thinking].call(*args)
|
||||
|
||||
@@ -14,8 +14,11 @@ module Enterprise::AutoAssignment::AssignmentService
|
||||
end
|
||||
|
||||
# Extend agent finding to add capacity checks
|
||||
def find_available_agent
|
||||
agents = filter_agents_by_rate_limit(inbox.available_agents)
|
||||
def find_available_agent(conversation = nil)
|
||||
agents = filter_agents_by_team(inbox.available_agents, conversation)
|
||||
return nil if agents.nil?
|
||||
|
||||
agents = filter_agents_by_rate_limit(agents)
|
||||
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
|
||||
return nil if agents.empty?
|
||||
|
||||
|
||||
@@ -31,12 +31,20 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
end
|
||||
|
||||
def fetch_audio_file
|
||||
blob = attachment.file.blob
|
||||
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
|
||||
FileUtils.mkdir_p(temp_dir)
|
||||
temp_file_path = File.join(temp_dir, "#{attachment.file.blob.key}-#{attachment.file.filename}")
|
||||
temp_file_name = "#{blob.key}-#{blob.filename}"
|
||||
|
||||
if blob.filename.extension_without_delimiter.blank?
|
||||
extension = extension_from_content_type(blob.content_type)
|
||||
temp_file_name = "#{temp_file_name}.#{extension}" if extension.present?
|
||||
end
|
||||
|
||||
temp_file_path = File.join(temp_dir, temp_file_name)
|
||||
|
||||
File.open(temp_file_path, 'wb') do |file|
|
||||
attachment.file.blob.open do |blob_file|
|
||||
blob.open do |blob_file|
|
||||
IO.copy_stream(blob_file, file)
|
||||
end
|
||||
end
|
||||
@@ -49,13 +57,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
return transcribed_text if transcribed_text.present?
|
||||
|
||||
temp_file_path = fetch_audio_file
|
||||
|
||||
transcribed_text = nil
|
||||
|
||||
File.open(temp_file_path, 'rb') do |file|
|
||||
response = @client.audio.transcribe(
|
||||
parameters: {
|
||||
model: 'whisper-1',
|
||||
model: WHISPER_MODEL,
|
||||
file: file,
|
||||
temperature: 0.4
|
||||
}
|
||||
@@ -63,10 +70,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
transcribed_text = response['text']
|
||||
end
|
||||
|
||||
FileUtils.rm_f(temp_file_path)
|
||||
|
||||
update_transcription(transcribed_text)
|
||||
transcribed_text
|
||||
ensure
|
||||
FileUtils.rm_f(temp_file_path) if temp_file_path.present?
|
||||
end
|
||||
|
||||
def instrumentation_params(file_path)
|
||||
@@ -90,4 +97,15 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
|
||||
message.reindex
|
||||
end
|
||||
|
||||
def extension_from_content_type(content_type)
|
||||
subtype = content_type.to_s.downcase.split(';').first.to_s.split('/').last.to_s
|
||||
return if subtype.blank?
|
||||
|
||||
{
|
||||
'x-m4a' => 'm4a',
|
||||
'x-wav' => 'wav',
|
||||
'x-mp3' => 'mp3'
|
||||
}.fetch(subtype, subtype)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,6 +15,7 @@ module Captain::ToolInstrumentation
|
||||
response = yield
|
||||
executed = true
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json)
|
||||
set_tool_session_error_attributes(span, response) if response.is_a?(Hash)
|
||||
end
|
||||
response
|
||||
rescue StandardError => e
|
||||
@@ -29,6 +30,14 @@ module Captain::ToolInstrumentation
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
|
||||
end
|
||||
|
||||
def set_tool_session_error_attributes(span, response)
|
||||
error = response[:error] || response['error']
|
||||
return if error.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
|
||||
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
|
||||
end
|
||||
|
||||
def record_generation(chat, message, model)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
|
||||
|
||||
@@ -37,6 +37,7 @@ module Integrations::LlmInstrumentation
|
||||
result = yield
|
||||
executed = true
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
|
||||
set_error_attributes(span, result) if result.is_a?(Hash)
|
||||
result
|
||||
end
|
||||
rescue StandardError => e
|
||||
@@ -50,9 +51,11 @@ module Integrations::LlmInstrumentation
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
|
||||
result = yield
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
|
||||
set_error_attributes(span, result) if result.is_a?(Hash)
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,6 +26,7 @@ module Integrations::LlmInstrumentationConstants
|
||||
ATTR_LANGFUSE_METADATA = 'langfuse.trace.metadata.%s'
|
||||
ATTR_LANGFUSE_TRACE_INPUT = 'langfuse.trace.input'
|
||||
ATTR_LANGFUSE_TRACE_OUTPUT = 'langfuse.trace.output'
|
||||
ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type'
|
||||
ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input'
|
||||
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
|
||||
end
|
||||
|
||||
@@ -39,6 +39,7 @@ module Integrations::LlmInstrumentationSpans
|
||||
|
||||
tool_name = tool_call.name.to_s
|
||||
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
|
||||
|
||||
@pending_tool_spans ||= []
|
||||
|
||||
@@ -330,6 +330,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
context 'when it is an authenticated user who has access to the inbox' do
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
create(:team_member, user: agent, team: team)
|
||||
end
|
||||
|
||||
it 'creates a new conversation' do
|
||||
|
||||
@@ -75,6 +75,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
|
||||
it 'runs agent with extracted user message and context' do
|
||||
expected_context = {
|
||||
session_id: "#{account.id}_#{conversation.display_id}",
|
||||
conversation_history: [
|
||||
{ role: :user, content: 'Hello there', agent_name: nil },
|
||||
{ role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' },
|
||||
@@ -306,6 +307,60 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#add_usage_metadata_callback' do
|
||||
it 'sets credits_used=false when handoff tool is used' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation)
|
||||
runner = instance_double(Agents::AgentRunner)
|
||||
tool_complete_callback = nil
|
||||
run_complete_callback = nil
|
||||
span_class = Class.new do
|
||||
def set_attribute(*); end
|
||||
end
|
||||
root_span = instance_double(span_class)
|
||||
context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } })
|
||||
|
||||
allow(ChatwootApp).to receive(:otel_enabled?).and_return(true)
|
||||
allow(runner).to receive(:on_tool_complete) do |&block|
|
||||
tool_complete_callback = block
|
||||
runner
|
||||
end
|
||||
allow(runner).to receive(:on_run_complete) do |&block|
|
||||
run_complete_callback = block
|
||||
runner
|
||||
end
|
||||
|
||||
service.send(:add_usage_metadata_callback, runner)
|
||||
|
||||
tool_complete_callback.call(Captain::Tools::HandoffTool.new(assistant).name, 'ok', context_wrapper)
|
||||
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credits_used', false)
|
||||
run_complete_callback.call('assistant', nil, context_wrapper)
|
||||
end
|
||||
|
||||
it 'sets credits_used=true when handoff tool is not used' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation)
|
||||
runner = instance_double(Agents::AgentRunner)
|
||||
run_complete_callback = nil
|
||||
span_class = Class.new do
|
||||
def set_attribute(*); end
|
||||
end
|
||||
root_span = instance_double(span_class)
|
||||
context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } })
|
||||
|
||||
allow(ChatwootApp).to receive(:otel_enabled?).and_return(true)
|
||||
allow(runner).to receive(:on_tool_complete).and_return(runner)
|
||||
allow(runner).to receive(:on_run_complete) do |&block|
|
||||
run_complete_callback = block
|
||||
runner
|
||||
end
|
||||
|
||||
service.send(:add_usage_metadata_callback, runner)
|
||||
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credits_used', true)
|
||||
run_complete_callback.call('assistant', nil, context_wrapper)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'constants' do
|
||||
it 'defines conversation state attributes' do
|
||||
expect(described_class::CONVERSATION_STATE_ATTRIBUTES).to include(
|
||||
|
||||
@@ -8,8 +8,8 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
|
||||
|
||||
before do
|
||||
# Create required installation configs
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-api-key')
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
|
||||
InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_API_KEY') { |config| config.value = 'test-api-key' }
|
||||
InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_MODEL') { |config| config.value = 'gpt-4o-mini' }
|
||||
|
||||
# Mock usage limits for transcription to be available
|
||||
allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } })
|
||||
@@ -64,4 +64,24 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fetch_audio_file' do
|
||||
let(:service) { described_class.new(attachment) }
|
||||
|
||||
before do
|
||||
attachment.file.attach(
|
||||
io: File.open(Rails.public_path.join('audio/widget/ding.mp3')),
|
||||
filename: 'speech',
|
||||
content_type: 'audio/mpeg'
|
||||
)
|
||||
end
|
||||
|
||||
it 'adds extension from content type when filename has no extension' do
|
||||
temp_file_path = service.send(:fetch_audio_file)
|
||||
|
||||
expect(File.extname(temp_file_path)).to eq('.mpeg')
|
||||
ensure
|
||||
FileUtils.rm_f(temp_file_path) if temp_file_path.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -307,5 +307,52 @@ RSpec.describe AutoAssignment::AssignmentService do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with team assignments' do
|
||||
let(:team) { create(:team, account: account, allow_auto_assign: true) }
|
||||
let(:team_member) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
|
||||
|
||||
before do
|
||||
create(:team_member, team: team, user: team_member)
|
||||
create(:inbox_member, inbox: inbox, user: team_member)
|
||||
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ team_member.id.to_s => 'online' })
|
||||
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
|
||||
allow(rate_limiter).to receive(:within_limit?).and_return(true)
|
||||
allow(rate_limiter).to receive(:track_assignment)
|
||||
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(team_member)
|
||||
end
|
||||
|
||||
it 'assigns conversation with team to team member' do
|
||||
conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(conversation_with_team.reload.assignee).to eq(team_member)
|
||||
end
|
||||
|
||||
it 'skips assignment when team has allow_auto_assign false' do
|
||||
team.update!(allow_auto_assign: false)
|
||||
conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(conversation_with_team.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'skips assignment when no team members are available' do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
|
||||
conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil)
|
||||
|
||||
service.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(conversation_with_team.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user