Compare commits

..
Author SHA1 Message Date
Shivam Mishra e204c2aa4f chore: re-enable test 2025-05-27 18:32:44 +05:30
Shivam Mishra 1acc994d98 Revert "feat: re-enable all ci config"
This reverts commit 58e5f7e4df.
2025-05-27 18:23:22 +05:30
Shivam Mishra 5185bbd34f test: clear mutex before each step 2025-05-27 18:21:31 +05:30
Shivam Mishra 58e5f7e4df feat: re-enable all ci config 2025-05-27 18:19:38 +05:30
Shivam Mishra 7a5b170ff0 fix(ci): cc reporter folder 2025-05-27 18:11:23 +05:30
Shivam Mishra 6c57dbb12d chore(ci): disable frontend items 2025-05-27 18:06:03 +05:30
Shivam Mishra c174ee65e3 feat: run in serial 2025-05-27 17:58:26 +05:30
Shivam Mishra 97b36a868e chore: disable failing test 2025-05-27 17:08:22 +05:30
Shivam Mishra cb8821afbf style: line length 2025-05-27 14:56:35 +05:30
Shivam Mishra cd71a6ec10 fix: better stub path 2025-05-27 14:47:23 +05:30
Shivam Mishra c1044a8b66 fix: accept json directly 2025-05-27 14:42:30 +05:30
Shivam Mishra 6e2a90113c test: fix specs for search linear issue service 2025-05-27 12:35:53 +05:30
Sivin VargheseandGitHub 9bd658137a feat: Scroll lock on message context menu (#11454)
This PR uses `useScrollLock` from `VueUse` to lock scrolling on the
conversation panel when the message context menu is open.
2025-05-23 16:12:18 +05:30
Sojan JoseandGitHub f73c5ef0b8 chore: Add short_description translations for integration apps (#11562)
- Add short_description translations for integration apps (dyte, slack,
dialogflow, google_translate, openai, linear, shopify)
- Remove fallback logic in NewHook.vue since I18n.t() returns
translation key string when missing (making fallback ineffective)
2025-05-23 01:25:13 -07:00
Shivam MishraandGitHub f9fce5e2df style: run rubocop with auto fix (#11563)
- fix rubocop issues
2025-05-23 01:23:33 -07:00
Sojan JoseandGitHub c2d8e2ad77 feat: move Slack config to installation settings (#11548)
- enable Slack Configuration via InstallationConfig
- list Slack integration in super admin settings
2025-05-23 01:07:35 -07:00
PranavandGitHub 03c0a7c62e feat: Add support for more tool, standardize copilot chat service (#11560) 2025-05-23 01:07:07 -07:00
d40a59f7fa feat: automate account deletion (#11406)
- Automate the deletion of accounts that have requested deletion via
account settings.
- Add a Sidekiq job that runs daily to find accounts that have requested
deletion and have passed the 7-day window.
- This job deletes the account and then soft-deletes users if they do
not belong to any other account.
- This job also sends an email to the Chatwoot instance admin for
compliance purposes.
- The Chatwoot instance admin's email is configurable via the
`CHATWOOT_INSTANCE_ADMIN_EMAIL` global config.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-05-23 12:58:13 +05:30
PranavandGitHub 72c5671e09 feat: Add support for the temperature field (#11554)
<img width="460" alt="Screenshot 2025-05-22 at 3 10 22 PM"
src="https://github.com/user-attachments/assets/e1c6bbd7-cd28-4808-99cb-ebc322531987"
/>


This is a stop-gap solution.
2025-05-22 23:03:10 -07:00
PranavandGitHub 8c0885e1d2 feat: Add support for realtime-events in copilot-threads and copilot-messages (#11557)
- Add API support for creating a thread
- Add API support for creating a message
- Remove uuid from thread (no longer required, we will use existing
websocket connection to send messages)
- Update message_type to a column (user, assistant, assistant_thinking)
2025-05-22 22:25:05 -07:00
Sivin VargheseandGitHub e92f72b318 chore: Prevent online presence during impersonation (#11538) 2025-05-23 09:53:40 +05:30
96 changed files with 1882 additions and 991 deletions
+40 -40
View File
@@ -99,17 +99,17 @@ jobs:
chmod +x ~/tmp/cc-test-reporter
# Swagger verification
- run:
name: Verify swagger API specification
command: |
bundle exec rake swagger:build
if [[ `git status swagger/swagger.json --porcelain` ]]
then
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# - run:
# name: Verify swagger API specification
# command: |
# bundle exec rake swagger:build
# if [[ `git status swagger/swagger.json --porcelain` ]]
# then
# echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
# exit 1
# fi
# curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
# java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# we remove the FRONTED_URL from the .env before running the tests
- run:
@@ -139,26 +139,26 @@ jobs:
command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting
- run:
name: Rubocop
command: bundle exec rubocop
# - run:
# name: Rubocop
# command: bundle exec rubocop
# ESLint linting
- run:
name: eslint
command: pnpm run eslint
# # ESLint linting
# - run:
# name: eslint
# command: pnpm run eslint
- run:
name: Run frontend tests
command: |
mkdir -p ~/build/coverage/frontend
~/tmp/cc-test-reporter before-build
pnpm run test:coverage
# - run:
# name: Run frontend tests
# command: |
# mkdir -p ~/build/coverage/frontend
# ~/tmp/cc-test-reporter before-build
# pnpm run test:coverage
- run:
name: Code Climate Test Coverage (Frontend)
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# - run:
# name: Code Climate Test Coverage (Frontend)
# command: |
# ~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# Run backend tests
- run:
@@ -175,17 +175,17 @@ jobs:
-- ${TESTFILES}
no_output_timeout: 30m
- run:
name: Code Climate Test Coverage (Backend)
command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
# - run:
# name: Code Climate Test Coverage (Backend)
# command: |
# ~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
- run:
name: List coverage directory contents
command: |
ls -R ~/build/coverage
# - run:
# name: List coverage directory contents
# command: |
# ls -R ~/build/coverage
- persist_to_workspace:
root: ~/build
paths:
- coverage
# - persist_to_workspace:
# root: ~/build
# paths:
# - coverage
@@ -32,22 +32,17 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end
def allowed_configs
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'shopify'
%w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET]
when 'microsoft'
%w[AZURE_APP_ID AZURE_APP_SECRET]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
when 'linear'
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
when 'instagram'
%w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end
mapping = {
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
end
end
@@ -134,6 +134,10 @@ class ConversationApi extends ApiClient {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
}
@@ -3,7 +3,7 @@
}
.tabs--container--with-border {
@apply border-b border-b-n-weak;
@apply border-b border-n-weak;
}
.tabs--container--compact.tab--chat-type {
@@ -42,6 +42,7 @@ const initialState = {
conversationFaqs: false,
memories: false,
},
temperature: 1,
};
const state = reactive({ ...initialState });
@@ -87,6 +88,7 @@ const updateStateFromAssistant = assistant => {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
};
state.temperature = config.temperature || 1;
};
const handleBasicInfoUpdate = async () => {
@@ -136,6 +138,7 @@ const handleInstructionsUpdate = async () => {
const payload = {
config: {
...props.assistant.config,
temperature: state.temperature || 1,
instructions: state.instructions,
},
};
@@ -212,7 +215,7 @@ watch(
<!-- Instructions Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-4">
<Editor
v-model="state.instructions"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
@@ -221,6 +224,25 @@ watch(
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2 mt-4">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div class="flex justify-end">
<Button
size="small"
@@ -1,5 +1,6 @@
<script setup>
import { nextTick, ref, watch, computed } from 'vue';
import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -7,10 +8,8 @@ import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import CopilotHeader from './CopilotHeader.vue';
import CopilotEmptyState from './CopilotEmptyState.vue';
import Icon from '../icon/Icon.vue';
const props = defineProps({
supportAgent: {
@@ -27,7 +26,7 @@ const props = defineProps({
},
conversationInboxType: {
type: String,
default: '',
required: true,
},
assistants: {
type: Array,
@@ -39,13 +38,22 @@ const props = defineProps({
},
});
const emit = defineEmits(['sendMessage', 'reset', 'setAssistant', 'close']);
const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const { t } = useI18n();
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => {
emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
};
const useSuggestion = opt => {
emit('sendMessage', t(opt.prompt));
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const handleReset = () => {
emit('reset');
};
@@ -59,28 +67,20 @@ const scrollToBottom = async () => {
}
};
const groupedMessages = computed(() => {
const result = [];
let thinkingGroup = [];
props.messages.forEach(message => {
if (message.role === 'assistant_thinking') {
thinkingGroup.push(message);
} else {
if (thinkingGroup.length > 0) {
result.push({ type: 'thinking_group', messages: thinkingGroup });
thinkingGroup = [];
}
result.push({ type: 'message', message });
}
});
if (thinkingGroup.length > 0) {
result.push({ type: 'thinking_group', messages: thinkingGroup });
}
return result;
});
const promptOptions = [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
];
watch(
[() => props.messages, () => props.isCaptainTyping],
@@ -93,62 +93,60 @@ watch(
<template>
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
<CopilotHeader
:has-messages="messages.length > 0"
@reset="handleReset"
@close="$emit('close')"
/>
<div
ref="chatContainer"
class="flex-1 flex px-4 py-4 overflow-y-auto items-start"
>
<div v-if="messages.length" class="space-y-6 flex-1 flex flex-col w-full">
<template
v-for="item in groupedMessages"
:key="item.type === 'message' ? item.message.id : 'thinking-group'"
>
<template v-if="item.type === 'message'">
<CopilotAgentMessage
v-if="item.message.role === 'user'"
:support-agent="supportAgent"
:message="item.message"
/>
<CopilotAssistantMessage
v-else-if="
item.message.role === 'assistant' ||
item.message.role === 'system'
"
:message="item.message"
:conversation-inbox-type="conversationInboxType"
/>
</template>
<CopilotThinkingGroup
v-else
:messages="item.messages"
:has-assistant-message-after="
groupedMessages[groupedMessages.indexOf(item) + 1]?.message
?.role === 'assistant'
"
/>
</template>
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
v-if="message.role === 'user'"
:support-agent="supportAgent"
:message="message"
/>
<CopilotAssistantMessage
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
:message="message"
:conversation-inbox-type="conversationInboxType"
/>
</template>
<CopilotLoader v-if="isCaptainTyping" />
<CopilotLoader v-if="isCaptainTyping" />
</div>
<div
v-if="!messages.length"
class="h-full w-full flex items-center justify-center"
>
<div class="h-fit px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span>
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
<CopilotEmptyState
v-if="!messages.length"
@use-suggestion="sendMessage"
/>
</div>
<div class="mx-3 mt-px mb-2">
<div class="flex items-center gap-2 justify-between w-full mb-1">
<ToggleCopilotAssistant
v-if="assistants.length > 1"
v-if="assistants.length"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="$event => emit('setAssistant', $event)"
/>
<div v-else />
<button
v-if="messages.length"
class="text-xs flex items-center gap-1 hover:underline"
@click="handleReset"
>
<i class="i-lucide-refresh-ccw" />
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
</button>
</div>
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
</div>
@@ -17,7 +17,7 @@ const props = defineProps({
},
conversationInboxType: {
type: String,
default: '',
required: true,
},
});
@@ -1,91 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Icon from '../icon/Icon.vue';
const emit = defineEmits(['useSuggestion']);
const { t } = useI18n();
const route = useRoute();
const routePromptMap = {
conversations: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
],
dashboard: [
{
label: 'High priority conversations',
prompt: 'Get me a summary of all high priority open conversations',
},
{
label: 'List contacts',
prompt: 'Show me the list of contacts',
},
{
label: 'Summarize articles',
prompt: 'List articles that are in draft',
},
],
};
const getCurrentRoute = () => {
const path = route.path;
if (path.includes('/conversations')) return 'conversations';
if (path.includes('/dashboard')) return 'dashboard';
if (path.includes('/contacts')) return 'contacts';
if (path.includes('/articles')) return 'articles';
return 'dashboard';
};
const promptOptions = computed(() => {
const currentRoute = getCurrentRoute();
return routePromptMap[currentRoute] || routePromptMap.conversations;
});
const handleSuggestion = opt => {
emit('useSuggestion', t(opt.prompt));
};
</script>
<template>
<div class="flex-1 flex flex-col gap-6 px-2">
<div class="flex flex-col space-y-4 py-4">
<Icon icon="i-woot-captain" class="text-n-slate-9 text-4xl" />
<div class="space-y-1">
<h3 class="text-base font-medium text-n-slate-12 leading-8">
{{ $t('CAPTAIN.COPILOT.PANEL_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.COPILOT.KICK_OFF_MESSAGE') }}
</p>
</div>
</div>
<div class="w-full space-y-2">
<span class="text-xs text-n-slate-10 block">
{{ $t('CAPTAIN.COPILOT.TRY_THESE_PROMPTS') }}
</span>
<div class="space-y-1">
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="w-full px-3 py-2 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center justify-between hover:bg-n-slate-3 transition-colors"
@click="handleSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
</div>
</div>
</template>
@@ -11,7 +11,7 @@ defineEmits(['reset', 'close']);
<template>
<div
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
class="flex items-center justify-between px-5 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">
@@ -1,47 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useStore } from 'vuex';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import { useMapGetter } from 'dashboard/composables/store';
const store = useStore();
const route = useRoute();
const getUIState = useMapGetter('uiState/getUIState');
const isSidebarOpen = computed(() => getUIState.value('isCopilotSidebarOpen'));
const isConversationRoute = computed(() => {
const CONVERSATION_ROUTES = [
'inbox_conversation',
'conversation_through_inbox',
'conversations_through_label',
'team_conversations_through_label',
'conversations_through_folders',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
];
return CONVERSATION_ROUTES.includes(route.name);
});
const toggleSidebar = () => {
store.dispatch('uiState/toggle', 'isCopilotSidebarOpen');
};
</script>
<template>
<div class="fixed bottom-4 right-4 z-50">
<div
v-if="!isSidebarOpen && !isConversationRoute"
class="rounded-full bg-n-solid-2 p-2 hover:bg-n-solid-3"
>
<Button
icon="i-woot-captain"
class="!rounded-full !bg-n-alpha-2 text-n-slate-12 text-xl"
lg
@click="toggleSidebar"
/>
</div>
</div>
</template>
@@ -315,11 +315,7 @@ const componentToRender = computed(() => {
});
const shouldShowContextMenu = computed(() => {
return !(
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS ||
props.contentAttributes?.isUnsupported
);
return !props.contentAttributes?.isUnsupported;
});
const isBubble = computed(() => {
@@ -344,12 +340,23 @@ const contextMenuEnabledOptions = computed(() => {
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
const isFailedOrProcessing =
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS;
return {
copy: hasText,
delete: hasText || hasAttachments,
cannedResponse: isOutgoing && hasText,
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
delete:
(hasText || hasAttachments) &&
!isFailedOrProcessing &&
!isMessageDeleted.value,
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
copyLink: !isFailedOrProcessing,
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
replyTo:
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
};
});
@@ -499,8 +506,8 @@ provideMessageContext({
<div
class="[grid-area:bubble] flex"
:class="{
'ltr:pl-8 rtl:pr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:pr-8 rtl:pl-8': orientation === ORIENTATION.LEFT,
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}"
@contextmenu="openContextMenu($event)"
@@ -516,7 +523,7 @@ provideMessageContext({
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
<ContextMenu
v-if="isBubble && !isMessageDeleted"
v-if="isBubble"
:context-menu-position="contextMenuPosition"
:is-open="showContextMenu"
:enabled-options="contextMenuEnabledOptions"
@@ -519,7 +519,7 @@ const menuItems = computed(() => {
</div>
</section>
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
<ul class="flex flex-col gap-1.5 m-0 list-none">
<ul class="flex flex-col gap-2 m-0 list-none">
<SidebarGroup
v-for="item in menuItems"
:key="item.name"
@@ -8,7 +8,6 @@ import {
computed,
watch,
onMounted,
onUnmounted,
defineEmits,
} from 'vue';
import { useStore } from 'vuex';
@@ -44,7 +43,7 @@ import {
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter';
import { useEventListener, useScrollLock } from '@vueuse/core';
import { useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
@@ -87,12 +86,8 @@ const store = useStore();
const conversationListRef = ref(null);
const conversationDynamicScroller = ref(null);
const conversationListScrollableElement = computed(
() => conversationDynamicScroller.value?.$el
);
const conversationListScrollLock = useScrollLock(
conversationListScrollableElement
);
provide('contextMenuElementTarget', conversationDynamicScroller);
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
@@ -746,7 +741,6 @@ function allSelectedConversationsStatus(status) {
function onContextMenuToggle(state) {
isContextMenuOpen.value = state;
conversationListScrollLock.value = state;
}
function toggleSelectAll(check) {
@@ -770,10 +764,6 @@ onMounted(() => {
}
});
onUnmounted(() => {
conversationListScrollLock.value = false;
});
provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
@@ -824,7 +814,7 @@ watch(conversationFilters, (newVal, oldVal) => {
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[21rem] 2xl:w-[26rem]',
isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
]"
>
<slot />
@@ -80,14 +80,16 @@ const toggleConversationLayout = () => {
<template>
<div
class="flex items-center justify-between gap-2 px-3 h-12 pt-3 pb-3"
class="flex items-center justify-between gap-2 px-4"
:class="{
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
'pt-3 pb-2': showV4View,
'mb-2 pb-0': !showV4View,
}"
>
<div class="flex items-center justify-center min-w-0">
<h1
class="text-base font-medium truncate text-n-slate-12"
class="text-lg font-medium truncate text-n-slate-12"
:title="pageTitle"
>
{{ pageTitle }}
@@ -1,24 +1,31 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, watchEffect } from 'vue';
import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import ConversationAPI from 'dashboard/api/inbox/conversation';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
conversationInboxType: {
type: String,
required: true,
},
});
const store = useStore();
const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords');
const inboxAssistant = useMapGetter('getCopilotAssistant');
const { uiSettings, updateUISettings } = useUISettings();
const currentChat = useMapGetter('getSelectedChat');
const getUIState = useMapGetter('uiState/getUIState');
const isSidebarOpen = computed(() => getUIState.value('isCopilotSidebarOpen'));
const messages = ref([]);
const isCaptainTyping = ref(false);
const selectedAssistantId = ref(null);
const copilotConnector = ref(null);
const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id;
@@ -52,12 +59,7 @@ const handleReset = () => {
messages.value = [];
};
const sendMessage = message => {
// Ensure WebSocket is connected before sending
if (!copilotConnector.value || !isSidebarOpen.value) {
return;
}
const sendMessage = async message => {
// Add user message
messages.value.push({
id: messages.value.length + 1,
@@ -66,71 +68,55 @@ const sendMessage = message => {
});
isCaptainTyping.value = true;
copilotConnector.value.sendMessage({
assistantId: activeAssistant.value.id,
conversationId: currentChat.value?.id,
previousHistory: messages.value
.filter(m => m.role !== 'assistant_thinking')
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
});
try {
const { data } = await ConversationAPI.requestCopilot(
props.conversationId,
{
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
assistant_id: selectedAssistantId.value,
}
);
messages.value.push({
id: new Date().getTime(),
role: 'assistant',
content: data.message,
});
} catch (error) {
// eslint-disable-next-line
console.log(error);
} finally {
isCaptainTyping.value = false;
}
};
onMounted(() => {
store.dispatch('captainAssistants/get');
});
// const onCopilotResponse = data => {
// if (data.type === 'final_response') {
// messages.value.push({
// id: new Date().getTime(),
// role: 'assistant',
// content: data.response.response,
// });
// isCaptainTyping.value = false;
// } else if (data.type === 'ui_event') {
// if (data.event === 'ui:linear_ticket_create') {
// emitter.emit('ui:linear_ticket_create');
// setTimeout(() => {
// emitter.emit('ui:linear_ticket_create_data', data.response);
// }, 100);
// }
// } else {
// messages.value.push({
// id: new Date().getTime(),
// role: 'assistant_thinking',
// content: data.response.response,
// reasoning: data.response.reasoning,
// });
// }
// };
const handleClose = () => {
store.dispatch('uiState/set', { isCopilotSidebarOpen: false });
};
watchEffect(() => {
if (props.conversationId) {
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
selectedAssistantId.value = activeAssistant.value?.id;
}
});
</script>
<template>
<div
v-if="isSidebarOpen"
class="border-l border-n-weak w-[20rem] min-w-[20rem]"
>
<Copilot
:messages="messages"
:support-agent="currentUser"
:is-captain-typing="isCaptainTyping"
:conversation-inbox-type="conversationInboxType"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="setAssistant"
@send-message="sendMessage"
@reset="handleReset"
@close="handleClose"
/>
</div>
<div v-else class="hidden" />
<Copilot
:messages="messages"
:support-agent="currentUser"
:is-captain-typing="isCaptainTyping"
:conversation-inbox-type="conversationInboxType"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="setAssistant"
@send-message="sendMessage"
@reset="handleReset"
/>
</template>
@@ -1,6 +1,13 @@
<script setup>
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
import { useWindowSize, useElementBounding } from '@vueuse/core';
import {
computed,
onMounted,
nextTick,
onUnmounted,
useTemplateRef,
inject,
} from 'vue';
import { useWindowSize, useElementBounding, useScrollLock } from '@vueuse/core';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
@@ -11,27 +18,34 @@ const props = defineProps({
const emit = defineEmits(['close']);
const elementToLock = inject('contextMenuElementTarget', null);
const menuRef = useTemplateRef('menuRef');
const scrollLockElement = computed(() => {
if (!elementToLock?.value) return null;
return elementToLock.value?.$el;
});
const isLocked = useScrollLock(scrollLockElement);
const { width: windowWidth, height: windowHeight } = useWindowSize();
const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef);
const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => {
const PADDING = 16;
// Initial position
let left = x;
let top = y;
// Boundary checks
const isOverflowingRight = left + menuW > windowW;
const isOverflowingBottom = top + menuH > windowH;
const isOverflowingRight = left + menuW > windowW - PADDING;
const isOverflowingBottom = top + menuH > windowH - PADDING;
// Adjust position if overflowing
if (isOverflowingRight) left = windowW - menuW;
if (isOverflowingBottom) top = windowH - menuH;
if (isOverflowingRight) left = windowW - menuW - PADDING;
if (isOverflowingBottom) top = windowH - menuH - PADDING;
return {
left: Math.max(0, left),
top: Math.max(0, top),
left: Math.max(PADDING, left),
top: Math.max(PADDING, top),
};
};
@@ -54,8 +68,18 @@ const position = computed(() => {
});
onMounted(() => {
isLocked.value = true;
nextTick(() => menuRef.value?.focus());
});
const handleClose = () => {
isLocked.value = false;
emit('close');
};
onUnmounted(() => {
isLocked.value = false;
});
</script>
<template>
@@ -65,7 +89,7 @@ onMounted(() => {
class="fixed outline-none z-[9999] cursor-pointer"
:style="position"
tabindex="0"
@blur="emit('close')"
@blur="handleClose"
>
<slot />
</div>
@@ -48,7 +48,7 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-3 -mt-1 py-0 tab--chat-type"
class="w-full px-4 py-0 tab--chat-type"
@change="onTabChange"
>
<woot-tabs-item
@@ -83,7 +83,7 @@ export default {
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div v-if="hasOpenedAtleastOnce" class="flex-1">
<div v-if="hasOpenedAtleastOnce" class="dashboard-app--container">
<div
v-for="(configItem, index) in config"
:key="index"
@@ -115,7 +115,6 @@ export default {
.dashboard-app--list iframe {
border: 0;
}
.dashboard-app_loading-container {
display: flex;
align-items: center;
@@ -4,9 +4,11 @@ import ConversationHeader from './ConversationHeader.vue';
import DashboardAppFrame from '../DashboardApp/Frame.vue';
import EmptyState from './EmptyState/EmptyState.vue';
import MessagesView from './MessagesView.vue';
import ConversationSidebar from './ConversationSidebar.vue';
export default {
components: {
ConversationSidebar,
ConversationHeader,
DashboardAppFrame,
EmptyState,
@@ -23,11 +25,16 @@ export default {
type: Boolean,
default: false,
},
isContactPanelOpen: {
type: Boolean,
default: true,
},
isOnExpandedLayout: {
type: Boolean,
default: true,
},
},
emits: ['contactPanelToggle'],
data() {
return { activeIndex: 0 };
},
@@ -50,6 +57,9 @@ export default {
})),
];
},
showContactPanel() {
return this.isContactPanelOpen && this.currentChat.id;
},
},
watch: {
'currentChat.inbox_id': {
@@ -76,6 +86,9 @@ export default {
}
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
onDashboardAppTabChange(index) {
this.activeIndex = index;
},
@@ -85,7 +98,7 @@ export default {
<template>
<div
class="conversation-details-wrap bg-n-background relative"
class="conversation-details-wrap bg-n-background"
:class="{
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
}"
@@ -94,12 +107,14 @@ export default {
v-if="currentChat.id"
:chat="currentChat"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout && !isInboxView"
@contact-panel-toggle="onToggleContactPanel"
/>
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
:index="activeIndex"
class="-mt-px border-t border-t-n-background bg-n-background dashboard-app--tabs"
class="-mt-px bg-white dashboard-app--tabs dark:bg-slate-900"
@change="onDashboardAppTabChange"
>
<woot-tabs-item
@@ -110,17 +125,23 @@ export default {
:show-badge="false"
/>
</woot-tabs>
<div v-show="!activeIndex" class="flex-1 h-full min-h-0 m-0">
<div v-show="!activeIndex" class="flex h-full min-h-0 m-0">
<MessagesView
v-if="currentChat.id"
:inbox-id="inboxId"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel"
/>
<EmptyState
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<slot />
<ConversationSidebar
v-if="showContactPanel"
:current-chat="currentChat"
@toggle-contact-panel="onToggleContactPanel"
/>
</div>
<DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps"
@@ -243,7 +243,7 @@ export default {
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat,
@@ -278,7 +278,7 @@ export default {
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
size="40px"
/>
</div>
<div
@@ -1,5 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
@@ -12,6 +13,8 @@ import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
BackButton,
@@ -20,6 +23,7 @@ export default {
Thumbnail,
SLACardLabel,
Linear,
NextButton,
},
mixins: [inboxMixin],
props: {
@@ -27,6 +31,10 @@ export default {
type: Object,
default: () => {},
},
isContactPanelOpen: {
type: Boolean,
default: false,
},
showBackButton: {
type: Boolean,
default: false,
@@ -36,6 +44,15 @@ export default {
default: false,
},
},
emits: ['contactPanelToggle'],
setup(props, { emit }) {
const keyboardEvents = {
'Alt+KeyO': {
action: () => emit('contactPanelToggle'),
},
};
useKeyboardEvents(keyboardEvents);
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
@@ -82,7 +99,13 @@ export default {
}
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
},
contactPanelToggleText() {
return `${
this.isContactPanelOpen
? this.$t('CONVERSATION.HEADER.CLOSE')
: this.$t('CONVERSATION.HEADER.OPEN')
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
},
inbox() {
const { inbox_id: inboxId } = this.chat;
return this.$store.getters['inboxes/getInbox'](inboxId);
@@ -110,7 +133,7 @@ export default {
<template>
<div
class="flex flex-col items-center justify-between px-3 border-b bg-n-background border-n-weak md:flex-row h-12"
class="flex flex-col items-center justify-between px-4 py-2 border-b bg-n-background border-n-weak md:flex-row"
>
<div
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
@@ -127,18 +150,20 @@ export default {
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
>
<div class="flex items-center max-w-full gap-1 p-0 m-0 w-fit">
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
<div
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
>
<NextButton link slate @click.prevent="$emit('contactPanelToggle')">
<span
class="text-base font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
</NextButton>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
@@ -155,11 +180,19 @@ export default {
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
<NextButton
link
xs
blue
:label="contactPanelToggleText"
@click="$emit('contactPanelToggle')"
/>
</div>
</div>
</div>
<div
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
:class="{ 'justify-end': isContactPanelOpen }"
>
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<Linear
@@ -1,8 +1,11 @@
<script setup>
import { computed, ref } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { computed } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
const props = defineProps({
currentChat: {
@@ -11,48 +14,69 @@ const props = defineProps({
},
});
const getUIState = useMapGetter('uiState/getUIState');
const isCopilotSidebarOpen = computed(() =>
getUIState.value('isCopilotSidebarOpen')
);
const isConversationSidebarOpen = computed(() =>
getUIState.value('isConversationSidebarOpen')
);
const emit = defineEmits(['toggleContactPanel']);
const showConversationSidebar = computed(
() =>
props.currentChat.id &&
!isCopilotSidebarOpen.value &&
isConversationSidebarOpen.value
);
const { t } = useI18n();
const store = useStore();
const closeConversationPanel = () => {
store.dispatch('uiState/set', { isConversationSidebarOpen: false });
const channelType = computed(() => props.currentChat?.meta?.channel || '');
const CONTACT_TABS_OPTIONS = [
{ key: 'CONTACT', value: 'contact' },
{ key: 'COPILOT', value: 'copilot' },
];
const tabs = computed(() => {
return CONTACT_TABS_OPTIONS.map(tab => ({
label: t(`CONVERSATION.SIDEBAR.${tab.key}`),
value: tab.value,
}));
});
const activeTab = ref(0);
const toggleContactPanel = () => {
emit('toggleContactPanel');
};
const handleTabChange = selectedTab => {
activeTab.value = tabs.value.findIndex(
tabItem => tabItem.value === selectedTab.value
);
};
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
</script>
<template>
<div
v-if="showConversationSidebar"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[20rem] min-w-[20rem] flex flex-col"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
>
<div class="flex flex-1 flex-col overflow-auto">
<div
class="flex items-center justify-between gap-2 px-4 py-2 border-b border-n-weak h-12"
>
<span class="font-medium text-sm text-n-slate-12">
{{ $t('CONVERSATION.SIDEBAR.ACTIONS') }}
</span>
<div class="flex items-center">
<Button icon="i-lucide-x" ghost sm @click="closeConversationPanel" />
</div>
</div>
<div v-if="showCopilotTab" class="p-2">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTab"
class="w-full [&>button]:w-full"
@tab-changed="handleTabChange"
/>
</div>
<div class="flex flex-1 overflow-auto">
<ContactPanel
:conversation-id="props.currentChat.id"
:inbox-id="props.currentChat.inbox_id"
v-if="!activeTab"
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
:on-toggle="toggleContactPanel"
/>
<CopilotContainer
v-else-if="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-inbox-type="channelType"
:conversation-id="currentChat.id"
class="flex-1"
/>
</div>
</div>
<div v-else class="hidden" />
</template>
@@ -185,8 +185,17 @@ export default {
contextMenuEnabledOptions() {
return {
copy: this.hasText,
delete: this.hasText || this.hasAttachments,
cannedResponse: this.isOutgoing && this.hasText,
delete:
(this.hasText || this.hasAttachments) &&
!this.isMessageDeleted &&
!this.isFailed,
cannedResponse:
this.isOutgoing && this.hasText && !this.isMessageDeleted,
copyLink: !this.isFailed || !this.isProcessing,
translate:
(!this.isFailed || !this.isProcessing) &&
!this.isMessageDeleted &&
this.hasText,
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
};
},
@@ -328,7 +337,7 @@ export default {
return !this.sender.type || this.sender.type === 'agent_bot';
},
shouldShowContextMenu() {
return !(this.isFailed || this.isPending || this.isUnsupported);
return !this.isUnsupported;
},
showAvatar() {
if (this.isOutgoing || this.isTemplate) {
@@ -1,5 +1,5 @@
<script>
import { ref } from 'vue';
import { ref, provide } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
@@ -38,6 +38,8 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Message,
@@ -45,10 +47,23 @@ export default {
ReplyBox,
Banner,
ConversationLabelSuggestion,
NextButton,
},
mixins: [inboxMixin],
props: {
isContactPanelOpen: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
emits: ['contactPanelToggle'],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const closePopOutReplyBox = () => {
@@ -84,6 +99,8 @@ export default {
FEATURE_FLAGS.CHATWOOT_V4
);
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
@@ -94,6 +111,7 @@ export default {
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
showNextBubbles,
conversationPanelRef,
};
},
data() {
@@ -185,6 +203,12 @@ export default {
isATweet() {
return this.conversationType === 'tweet';
},
isRightOrLeftIcon() {
if (this.isContactPanelOpen) {
return 'arrow-chevron-right';
}
return 'arrow-chevron-left';
},
getLastSeenAt() {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt;
@@ -420,6 +444,9 @@ export default {
relevantMessages
);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
setScrollParams() {
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
@@ -503,9 +530,22 @@ export default {
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<div class="flex justify-end">
<NextButton
faded
xs
slate
class="!rounded-r-none rtl:rotate-180 !rounded-2xl !fixed z-10"
:icon="
isContactPanelOpen ? 'i-ph-caret-right-fill' : 'i-ph-caret-left-fill'
"
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
@click="onToggleContactPanel"
/>
</div>
<NextMessageList
v-if="showNextBubbles"
ref="conversationPanelRef"
class="conversation-panel"
:current-user-id="currentUserId"
:first-unread-id="unReadMessages[0]?.id"
@@ -537,7 +577,7 @@ export default {
/>
</template>
</NextMessageList>
<ul v-else class="conversation-panel">
<ul v-else ref="conversationPanelRef" class="conversation-panel">
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]">
@@ -10,7 +10,6 @@ import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import SearchableDropdown from './SearchableDropdown.vue';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
import { useEmitter } from 'dashboard/composables/emitter';
const props = defineProps({
conversationId: {
@@ -205,12 +204,6 @@ const createIssue = async () => {
}
};
useEmitter('ui:linear_ticket_create_data', data => {
formState.title = data.title;
formState.description = data.description;
formState.priority = data.priority;
});
onMounted(getTeams);
</script>
@@ -10,7 +10,6 @@ import { useTrack } from 'dashboard/composables';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import Button from 'dashboard/components-next/button/Button.vue';
import { useEmitter } from 'dashboard/composables/emitter';
const props = defineProps({
conversationId: {
@@ -95,10 +94,6 @@ watch(
}
);
useEmitter('ui:linear_ticket_create', () => {
shouldShowPopup.value = true;
});
onMounted(() => {
loadLinkedIssue();
});
@@ -3,6 +3,9 @@ import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnec
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
const { isImpersonating } = useImpersonation();
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
@@ -52,6 +55,7 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onPresenceUpdate = data => {
if (isImpersonating.value) return;
this.app.$store.dispatch('contacts/updatePresence', data.contacts);
this.app.$store.dispatch('agents/updatePresence', data.users);
this.app.$store.dispatch('setCurrentUserAvailability', data.users);
@@ -234,7 +234,8 @@
}
},
"SIDEBAR": {
"ACTIONS": "Actions"
"CONTACT": "Contact",
"COPILOT": "Copilot"
}
},
"EMAIL_TRANSCRIPT": {
@@ -379,6 +380,9 @@
"TWO": "{user} and {secondUser} are typing",
"MULTIPLE": "{user} and {count} others are typing"
},
"COPILOT": {
"TRY_THESE_PROMPTS": "Try these prompts"
},
"GALLERY_VIEW": {
"ERROR_DOWNLOADING": "Unable to download attachment. Please try again"
}
@@ -328,17 +328,14 @@
"HEADER_KNOW_MORE": "Know more",
"COPILOT": {
"TITLE": "Copilot",
"TRY_THESE_PROMPTS": "Try these prompts",
"PANEL_TITLE": "Get started with Copilot",
"KICK_OFF_MESSAGE": "Need a quick summary, want to check past conversations, or draft a better reply? Copilots here to speed things up.",
"SEND_MESSAGE": "Send message...",
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
"LOADER": "Captain is thinking",
"YOU": "You",
"USE": "Use this",
"RESET": "Reset",
"SELECT_ASSISTANT": "Select Assistant",
"SHOW_STEPS": "Show steps",
"SELECT_ASSISTANT": "Select Assistant",
"PROMPTS": {
"SUMMARIZE": {
"LABEL": "Summarize this conversation",
@@ -370,7 +367,6 @@
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain AI feature is only available in a paid plan.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
@@ -413,6 +409,10 @@
"PLACEHOLDER": "Enter assistant name",
"ERROR": "The name is required"
},
"TEMPERATURE": {
"LABEL": "Response Temperature",
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter assistant description",
@@ -47,6 +47,7 @@ export default {
emits: ['open', 'close', 'replyTo'],
setup() {
const { getPlainText } = useMessageFormatter();
return {
getPlainText,
};
@@ -167,7 +168,7 @@ export default {
</woot-modal>
<!-- Confirm Deletion -->
<woot-delete-modal
v-if="showDeleteModal"
v-if="showDeleteModal && enabledOptions['delete']"
v-model:show="showDeleteModal"
class="context-menu--delete-modal"
:on-close="closeDeleteModal"
@@ -212,7 +213,7 @@ export default {
@click.stop="handleCopy"
/>
<MenuItem
v-if="enabledOptions['copy']"
v-if="enabledOptions['translate']"
:option="{
icon: 'translate',
label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'),
@@ -222,6 +223,7 @@ export default {
/>
<hr />
<MenuItem
v-if="enabledOptions['copyLink']"
:option="{
icon: 'link',
label: $t('CONVERSATION.CONTEXT_MENU.COPY_PERMALINK'),
@@ -1,15 +1,6 @@
<script setup>
import {
defineAsyncComponent,
ref,
computed,
onMounted,
onUnmounted,
nextTick,
watch,
} from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
<script>
import { defineAsyncComponent, ref } from 'vue';
import { mapGetters } from 'vuex';
import NextSidebar from 'next/sidebar/Sidebar.vue';
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
@@ -18,8 +9,6 @@ import AccountSelector from 'dashboard/components/layout/sidebarComponents/Accou
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.vue';
import UpgradePage from 'dashboard/routes/dashboard/upgrade/UpgradePage.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
@@ -27,160 +16,178 @@ import { useAccount } from 'dashboard/composables/useAccount';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { emitter } from 'shared/helpers/mitt';
const CommandBar = defineAsyncComponent(
() => import('./commands/commandbar.vue')
);
const Sidebar = defineAsyncComponent(
() => import('../../components/layout/Sidebar.vue')
);
import { emitter } from 'shared/helpers/mitt';
const route = useRoute();
export default {
components: {
NextSidebar,
Sidebar,
CommandBar,
WootKeyShortcutModal,
AddAccountModal,
AccountSelector,
AddLabelModal,
NotificationPanel,
UpgradePage,
},
setup() {
const upgradePageRef = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
const upgradePageRef = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const { currentAccount } = useAccount();
return {
uiSettings,
updateUISettings,
accountId,
upgradePageRef,
};
},
data() {
return {
showAccountModal: false,
showCreateAccountModal: false,
showAddLabelModal: false,
showShortcutModal: false,
isNotificationPanel: false,
displayLayoutType: '',
hasBanner: '',
};
},
computed: {
...mapGetters({
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentRoute() {
return ' ';
},
showUpgradePage() {
return this.upgradePageRef?.shouldShowUpgradePage;
},
bypassUpgradePage() {
return [
'billing_settings_index',
'settings_inbox_list',
'agent_list',
].includes(this.$route.name);
},
isSidebarOpen() {
const { show_secondary_sidebar: showSecondarySidebar } = this.uiSettings;
return showSecondarySidebar;
},
previouslyUsedDisplayType() {
const {
previously_used_conversation_display_type: conversationDisplayType,
} = this.uiSettings;
return conversationDisplayType;
},
previouslyUsedSidebarView() {
const { previously_used_sidebar_view: showSecondarySidebar } =
this.uiSettings;
return showSecondarySidebar;
},
showNextSidebar() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHATWOOT_V4
);
},
},
watch: {
displayLayoutType() {
const { LAYOUT_TYPES } = wootConstants;
this.updateUISettings({
conversation_display_type:
this.displayLayoutType === LAYOUT_TYPES.EXPANDED
? LAYOUT_TYPES.EXPANDED
: this.previouslyUsedDisplayType,
show_secondary_sidebar:
this.displayLayoutType === LAYOUT_TYPES.EXPANDED
? false
: this.previouslyUsedSidebarView,
});
},
},
mounted() {
this.handleResize();
this.$nextTick(this.checkBanner);
window.addEventListener('resize', this.handleResize);
window.addEventListener('resize', this.checkBanner);
emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
},
unmounted() {
window.removeEventListener('resize', this.handleResize);
window.removeEventListener('resize', this.checkBanner);
emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
},
const showAccountModal = ref(false);
const showCreateAccountModal = ref(false);
const showAddLabelModal = ref(false);
const showShortcutModal = ref(false);
const isNotificationPanel = ref(false);
const displayLayoutType = ref('');
const hasBanner = ref('');
methods: {
checkBanner() {
this.hasBanner =
document.getElementsByClassName('woot-banner').length > 0;
},
handleResize() {
const { SMALL_SCREEN_BREAKPOINT, LAYOUT_TYPES } = wootConstants;
let throttled = false;
const delay = 150;
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
if (throttled) {
return;
}
throttled = true;
const currentRoute = computed(() => ' ');
const showUpgradePage = computed(
() => upgradePageRef.value?.shouldShowUpgradePage
);
const bypassUpgradePage = computed(() =>
['billing_settings_index', 'settings_inbox_list', 'agent_list'].includes(
route.name
)
);
const isSidebarOpen = computed(() => {
const { show_secondary_sidebar: showSecondarySidebar } = uiSettings;
return showSecondarySidebar;
});
const previouslyUsedDisplayType = computed(() => {
const { previously_used_conversation_display_type: conversationDisplayType } =
uiSettings;
return conversationDisplayType;
});
const previouslyUsedSidebarView = computed(() => {
const { previously_used_sidebar_view: showSecondarySidebar } = uiSettings;
return showSecondarySidebar;
});
const showNextSidebar = computed(() =>
isFeatureEnabledonAccount.value(
currentAccount.value?.id,
FEATURE_FLAGS.CHATWOOT_V4
)
);
const checkBanner = () => {
hasBanner.value = document.getElementsByClassName('woot-banner').length > 0;
setTimeout(() => {
throttled = false;
if (window.innerWidth <= SMALL_SCREEN_BREAKPOINT) {
this.displayLayoutType = LAYOUT_TYPES.EXPANDED;
} else {
this.displayLayoutType = LAYOUT_TYPES.CONDENSED;
}
}, delay);
},
toggleSidebar() {
this.updateUISettings({
show_secondary_sidebar: !this.isSidebarOpen,
previously_used_sidebar_view: !this.isSidebarOpen,
});
},
openCreateAccountModal() {
this.showAccountModal = false;
this.showCreateAccountModal = true;
},
closeCreateAccountModal() {
this.showCreateAccountModal = false;
},
toggleAccountModal() {
this.showAccountModal = !this.showAccountModal;
},
toggleKeyShortcutModal() {
this.showShortcutModal = true;
},
closeKeyShortcutModal() {
this.showShortcutModal = false;
},
showAddLabelPopup() {
this.showAddLabelModal = true;
},
hideAddLabelPopup() {
this.showAddLabelModal = false;
},
openNotificationPanel() {
this.isNotificationPanel = true;
},
closeNotificationPanel() {
this.isNotificationPanel = false;
},
},
};
const handleResize = () => {
const { SMALL_SCREEN_BREAKPOINT, LAYOUT_TYPES } = wootConstants;
let throttled = false;
const delay = 150;
if (throttled) return;
throttled = true;
setTimeout(() => {
throttled = false;
displayLayoutType.value =
window.innerWidth <= SMALL_SCREEN_BREAKPOINT
? LAYOUT_TYPES.EXPANDED
: LAYOUT_TYPES.CONDENSED;
}, delay);
};
const toggleSidebar = () => {
updateUISettings({
show_secondary_sidebar: !isSidebarOpen.value,
previously_used_sidebar_view: !isSidebarOpen.value,
});
};
const openCreateAccountModal = () => {
showAccountModal.value = false;
showCreateAccountModal.value = true;
};
const closeCreateAccountModal = () => {
showCreateAccountModal.value = false;
};
const toggleAccountModal = () => {
showAccountModal.value = !showAccountModal.value;
};
const toggleKeyShortcutModal = () => {
showShortcutModal.value = true;
};
const closeKeyShortcutModal = () => {
showShortcutModal.value = false;
};
const showAddLabelPopup = () => {
showAddLabelModal.value = true;
};
const hideAddLabelPopup = () => {
showAddLabelModal.value = false;
};
const openNotificationPanel = () => {
isNotificationPanel.value = true;
};
const closeNotificationPanel = () => {
isNotificationPanel.value = false;
};
watch(displayLayoutType, () => {
const { LAYOUT_TYPES } = wootConstants;
updateUISettings({
conversation_display_type:
displayLayoutType.value === LAYOUT_TYPES.EXPANDED
? LAYOUT_TYPES.EXPANDED
: previouslyUsedDisplayType.value,
show_secondary_sidebar:
displayLayoutType.value === LAYOUT_TYPES.EXPANDED
? false
: previouslyUsedSidebarView.value,
});
});
onMounted(() => {
handleResize();
nextTick(checkBanner);
window.addEventListener('resize', handleResize);
window.addEventListener('resize', checkBanner);
emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, toggleSidebar);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('resize', checkBanner);
emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, toggleSidebar);
});
</script>
<template>
@@ -211,8 +218,6 @@ onUnmounted(() => {
/>
<template v-if="!showUpgradePage">
<router-view />
<CopilotLauncher />
<CopilotContainer />
<CommandBar />
<NotificationPanel
v-if="isNotificationPanel"
@@ -29,6 +29,10 @@ const props = defineProps({
type: Number,
default: undefined,
},
onToggle: {
type: Function,
default: () => {},
},
});
const {
@@ -85,6 +89,8 @@ watch(conversationId, (newConversationId, prevConversationId) => {
watch(contactId, getContactDetails);
const onPanelToggle = props.onToggle;
const onDragEnd = () => {
dragging.value = false;
updateUISettings({
@@ -101,7 +107,11 @@ onMounted(() => {
<template>
<div class="w-full">
<ContactInfo :contact="contact" :channel-type="channelType" />
<ContactInfo
:contact="contact"
:channel-type="channelType"
@toggle-panel="onPanelToggle"
/>
<div class="list-group pb-8">
<Draggable
:list="conversationSidebarItems"
@@ -10,17 +10,13 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
import { emitter } from 'shared/helpers/mitt';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
import PanelButtons from './PanelButtons.vue';
export default {
components: {
ChatList,
ConversationBox,
ConversationSidebar,
PopOverSearch,
CmdBarConversationSnooze,
PanelButtons,
},
beforeRouteLeave(to, from, next) {
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
@@ -91,6 +87,14 @@ export default {
this.uiSettings;
return conversationDisplayType !== CONDENSED;
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
}
return false;
},
showPopOverSearch() {
return !this.isFeatureEnabledonAccount(
this.accountId,
@@ -185,7 +189,11 @@ export default {
this.$store.dispatch('clearSelectedState');
}
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
onSearch() {
this.showSearchModal = true;
},
@@ -217,11 +225,10 @@ export default {
<ConversationBox
v-if="showMessageView"
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="isOnExpandedLayout"
>
<PanelButtons v-if="currentChat.id" />
</ConversationBox>
<ConversationSidebar :current-chat="currentChat" />
@contact-panel-toggle="onToggleContactPanel"
/>
<CmdBarConversationSnooze />
</section>
</template>
@@ -1,42 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import { useStore } from 'vuex';
const store = useStore();
const handleConversationSidebarToggle = () => {
store.dispatch('uiState/set', {
isConversationSidebarOpen: true,
isCopilotSidebarOpen: false,
});
};
const handleCopilotSidebarToggle = () => {
store.dispatch('uiState/set', {
isCopilotSidebarOpen: true,
isConversationSidebarOpen: false,
});
};
</script>
<template>
<div
class="flex flex-col justify-center items-center absolute top-24 right-1 bg-n-alpha-2 border border-n-weak rounded-lg bg-n-background gap-2 py-0.5 px-0.5"
>
<Button
ghost
slate
sm
class="!text-sm"
icon="i-ph-user-bold"
@click="handleConversationSidebarToggle"
/>
<Button
ghost
class="!text-sm !text-n-iris-11"
sm
icon="i-woot-captain"
@click="handleCopilotSidebarToggle"
/>
</div>
</template>
@@ -117,7 +117,7 @@ export default {
<div class="flex flex-col h-auto overflow-auto integration-hooks">
<woot-modal-header
:header-title="integration.name"
:header-content="integration.short_description || integration.description"
:header-content="integration.short_description"
/>
<FormKit
v-model="values"
-2
View File
@@ -51,7 +51,6 @@ import captainDocuments from './captain/document';
import captainResponses from './captain/response';
import captainInboxes from './captain/inboxes';
import captainBulkActions from './captain/bulkActions';
import uiState from './modules/uiState';
const plugins = [];
export default createStore({
@@ -107,7 +106,6 @@ export default createStore({
captainResponses,
captainInboxes,
captainBulkActions,
uiState,
},
plugins,
});
@@ -1,37 +0,0 @@
const initialState = {
isCopilotSidebarOpen: false,
isConversationSidebarOpen: true,
};
const types = {
SET_UI_STATE: 'SET_UI_STATE',
};
const mutations = {
[types.SET_UI_STATE](state, { key, value }) {
state[key] = value;
},
};
const getters = {
getUIState: state => key => state[key],
};
const actions = {
toggle({ commit, state }, key) {
commit(types.SET_UI_STATE, { key, value: !state[key] });
},
set({ commit }, payload) {
Object.entries(payload).forEach(([key, value]) => {
commit(types.SET_UI_STATE, { key, value });
});
},
};
export default {
namespaced: true,
state: initialState,
getters,
mutations,
actions,
};
+27
View File
@@ -0,0 +1,27 @@
class Internal::DeleteAccountsJob < ApplicationJob
queue_as :scheduled_jobs
def perform
delete_expired_accounts
end
private
def delete_expired_accounts
accounts_pending_deletion.each do |account|
AccountDeletionService.new(account: account).perform
end
end
def accounts_pending_deletion
Account.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL")
.select { |account| deletion_period_expired?(account) }
end
def deletion_period_expired?(account)
deletion_time = account.custom_attributes['marked_for_deletion_at']
return false if deletion_time.blank?
DateTime.parse(deletion_time) <= Time.current
end
end
+2
View File
@@ -204,3 +204,5 @@ class ActionCableListener < BaseListener
::ActionCableBroadcastJob.perform_later(tokens.uniq, event_name, payload)
end
end
ActionCableListener.prepend_mod_with('ActionCableListener')
@@ -0,0 +1,52 @@
class AdministratorNotifications::AccountComplianceMailer < AdministratorNotifications::BaseMailer
def account_deleted(account)
return if instance_admin_email.blank?
subject = subject_for(account)
meta = build_meta(account)
send_notification(subject, to: instance_admin_email, meta: meta)
end
private
def build_meta(account)
deleted_users = params[:soft_deleted_users] || []
user_info_list = deleted_users.map do |user|
{
'user_id' => user[:id].to_s,
'user_email' => user[:original_email].to_s
}
end
{
'instance_url' => instance_url,
'account_id' => account.id,
'account_name' => account.name,
'deleted_at' => format_time(Time.current.iso8601),
'deletion_reason' => account.custom_attributes['marked_for_deletion_reason'] || 'not specified',
'marked_for_deletion_at' => format_time(account.custom_attributes['marked_for_deletion_at']),
'soft_deleted_users' => user_info_list,
'deleted_user_count' => user_info_list.size
}
end
def format_time(time_string)
return 'not specified' if time_string.blank?
Time.zone.parse(time_string).strftime('%B %d, %Y %H:%M:%S %Z')
end
def subject_for(account)
"Account Deletion Notice for #{account.id} - #{account.name}"
end
def instance_admin_email
GlobalConfig.get('CHATWOOT_INSTANCE_ADMIN_EMAIL')['CHATWOOT_INSTANCE_ADMIN_EMAIL']
end
def instance_url
ENV.fetch('FRONTEND_URL', 'not available')
end
end
+3 -2
View File
@@ -39,7 +39,8 @@ class Integrations::App
def action
case params[:id]
when 'slack'
"#{params[:action]}&client_id=#{ENV.fetch('SLACK_CLIENT_ID', nil)}&redirect_uri=#{self.class.slack_integration_url}"
client_id = GlobalConfigService.load('SLACK_CLIENT_ID', nil)
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
when 'linear'
build_linear_action
else
@@ -50,7 +51,7 @@ class Integrations::App
def active?(account)
case params[:id]
when 'slack'
ENV['SLACK_CLIENT_SECRET'].present?
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear'
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
+50
View File
@@ -0,0 +1,50 @@
class AccountDeletionService
attr_reader :account, :soft_deleted_users
def initialize(account:)
@account = account
@soft_deleted_users = []
end
def perform
Rails.logger.info("Deleting account #{account.id} - #{account.name} that was marked for deletion")
soft_delete_orphaned_users
send_compliance_notification
DeleteObjectJob.perform_later(account)
end
private
def send_compliance_notification
AdministratorNotifications::AccountComplianceMailer.with(
account: account,
soft_deleted_users: soft_deleted_users
).account_deleted(account).deliver_later
end
def soft_delete_orphaned_users
account.users.each do |user|
# Find all account_users for this user excluding the current account
other_accounts = user.account_users.where.not(account_id: account.id).count
# If user has no other accounts, soft delete them
next unless other_accounts.zero?
# Soft delete user by appending -deleted.com to email
original_email = user.email
user.email = "#{original_email}-deleted.com"
user.skip_reconfirmation!
user.save!
user_info = {
id: user.id.to_s,
original_email: original_email
}
soft_deleted_users << user_info
Rails.logger.info("Soft deleted user #{user.id} with email #{original_email}")
end
end
end
@@ -0,0 +1,30 @@
<p>Hello,</p>
<p>This is a notification to inform you that an account has been permanently deleted from your Chatwoot instance.</p>
<p>
<strong>Chatwoot Installation:</strong> {{ meta.instance_url }}<br>
<strong>Account ID:</strong> {{ meta.account_id }}<br>
<strong>Account Name:</strong> {{ meta.account_name }}<br>
<strong>Deleted At:</strong> {{ meta.deleted_at }}<br>
<strong>Marked for Deletion at:</strong> {{ meta.marked_for_deletion_at }}<br>
<strong>Deletion Reason:</strong> {{ meta.deletion_reason }}
</p>
{% if meta.deleted_user_count > 0 %}
<p>
<strong>Deleted Users ({{ meta.deleted_user_count }}):</strong><br>
{% for user in meta.soft_deleted_users %}
User ID: {{ user.user_id }}, Email: {{ user.user_email }}{% unless forloop.last %}<br>{% endunless %}
{% endfor %}
</p>
{% else %}
<p>
<strong>Deleted Users:</strong> None
</p>
{% endif %}
<p>This email serves as a record for compliance purposes.</p>
<p>Thank you,<br>
Chatwoot System</p>
@@ -159,4 +159,7 @@
<symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
</svg>
<symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

+22
View File
@@ -235,6 +235,14 @@
description: Used to notify Chatwoot about account abuses, potential threads (Should be a Discord Webhook URL)
# ------- End of Chatwoot Internal Config for Self Hosted ----#
# ------- Compliance Related Config ----#
- name: CHATWOOT_INSTANCE_ADMIN_EMAIL
display_title: 'Instance Admin Email'
value:
description: 'The email of the instance administrator to receive compliance-related notifications'
locked: false
# ------- End of Compliance Related Config ----#
## ------ Configs added for enterprise clients ------ ##
- name: API_CHANNEL_NAME
value:
@@ -280,6 +288,20 @@
type: secret
## ------ End of Configs added for Linear ------ ##
## ------ Configs added for Slack ------ ##
- name: SLACK_CLIENT_ID
display_title: 'Slack Client ID'
value:
locked: false
description: 'Slack client ID'
- name: SLACK_CLIENT_SECRET
display_title: 'Slack Client Secret'
value:
locked: false
description: 'Slack client secret'
type: secret
## ------ End of Configs added for Slack ------ ##
# ------- Shopify Integration Config ------- #
- name: SHOPIFY_CLIENT_ID
display_title: 'Shopify Client ID'
+8
View File
@@ -213,33 +213,41 @@ en:
online:
delete: '%{contact_name} is Online, please try again later'
integration_apps:
# Note: webhooks and dashboard_apps don't need short_description as they use different modal components
dashboard_apps:
name: 'Dashboard Apps'
description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
dyte:
name: 'Dyte'
short_description: 'Start video/voice calls with customers directly from Chatwoot.'
description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
meeting_name: '%{agent_name} has started a meeting'
slack:
name: 'Slack'
short_description: 'Receive notifications and respond to conversations directly in Slack.'
description: "Integrate Chatwoot with Slack to keep your team in sync. This integration allows you to receive notifications for new conversations and respond to them directly within Slack's interface."
webhooks:
name: 'Webhooks'
description: 'Webhook events provide real-time updates about activities in your Chatwoot account. You can subscribe to your preferred events, and Chatwoot will send you HTTP callbacks with the updates.'
dialogflow:
name: 'Dialogflow'
short_description: 'Build chatbots to handle initial queries before transferring to agents.'
description: 'Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent.'
google_translate:
name: 'Google Translate'
short_description: 'Automatically translate customer messages for agents.'
description: "Integrate Google Translate to help agents easily translate customer messages. This integration automatically detects the language and converts it to the agent's or admin's preferred language."
openai:
name: 'OpenAI'
short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.'
linear:
name: 'Linear'
short_description: 'Create and link Linear issues directly from conversations.'
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
shopify:
name: 'Shopify'
short_description: 'Access order details and customer data from your Shopify store.'
description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.'
leadsquared:
name: 'LeadSquared'
+3 -2
View File
@@ -60,8 +60,8 @@ Rails.application.routes.draw do
end
resources :assistant_responses
resources :bulk_actions, only: [:create]
resources :copilot_threads, only: [:index] do
resources :copilot_messages, only: [:index]
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
end
resources :documents, only: [:index, :show, :create, :destroy]
end
@@ -127,6 +127,7 @@ Rails.application.routes.draw do
post :unread
post :custom_attributes
get :attachments
post :copilot
get :inbox_assistant
end
end
+7
View File
@@ -39,3 +39,10 @@ process_stale_contacts_job:
cron: '30 04 * * *'
class: 'Internal::ProcessStaleContactsJob'
queue: housekeeping
# executed daily at 0100 UTC
# to delete accounts marked for deletion
delete_accounts_job:
cron: '0 1 * * *'
class: 'Internal::DeleteAccountsJob'
queue: scheduled_jobs
@@ -0,0 +1,8 @@
class RemoveUuidFromCopilotThreads < ActiveRecord::Migration[7.1]
def change
remove_column :copilot_threads, :uuid, :string
add_column :copilot_threads, :assistant_id, :integer
add_index :copilot_threads, :assistant_id
end
end
@@ -0,0 +1,5 @@
class RemoveUserIdFromCopilotMessages < ActiveRecord::Migration[7.1]
def change
remove_reference :copilot_messages, :user, index: true
end
end
@@ -0,0 +1,11 @@
class ChangeMessageTypeToIntegerInCopilotMessages < ActiveRecord::Migration[7.1]
def up
remove_column :copilot_messages, :message_type
add_column :copilot_messages, :message_type, :integer, default: 0
end
def down
remove_column :copilot_messages, :message_type
add_column :copilot_messages, :message_type, :string, default: 'user'
end
end
+4 -6
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do
ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -577,27 +577,25 @@ ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do
create_table "copilot_messages", force: :cascade do |t|
t.bigint "copilot_thread_id", null: false
t.bigint "user_id", null: false
t.bigint "account_id", null: false
t.string "message_type", null: false
t.jsonb "message", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "message_type", default: 0
t.index ["account_id"], name: "index_copilot_messages_on_account_id"
t.index ["copilot_thread_id"], name: "index_copilot_messages_on_copilot_thread_id"
t.index ["user_id"], name: "index_copilot_messages_on_user_id"
end
create_table "copilot_threads", force: :cascade do |t|
t.string "title", null: false
t.bigint "user_id", null: false
t.bigint "account_id", null: false
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "assistant_id"
t.index ["account_id"], name: "index_copilot_threads_on_account_id"
t.index ["assistant_id"], name: "index_copilot_threads_on_assistant_id"
t.index ["user_id"], name: "index_copilot_threads_on_user_id"
t.index ["uuid"], name: "index_copilot_threads_on_uuid", unique: true
end
create_table "csat_survey_responses", force: :cascade do |t|
@@ -47,7 +47,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
config: [
:product_name, :feature_faq, :feature_memory,
:welcome_message, :handoff_message, :resolution_message,
:instructions
:instructions, :temperature
])
end
@@ -1,21 +1,28 @@
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_copilot_thread
def index
@copilot_messages = @copilot_thread
.copilot_messages
.includes(:copilot_thread)
.order(created_at: :asc)
.page(permitted_params[:page] || 1)
.per(1000)
end
def create
@copilot_message = @copilot_thread.copilot_messages.create!(
message: params[:message],
message_type: :user
)
end
private
def set_copilot_thread
@copilot_thread = Current.account.copilot_threads.find_by!(
uuid: params[:copilot_thread_id], user_id: Current.user.id
id: params[:copilot_thread_id],
user: Current.user
)
end
@@ -1,18 +1,41 @@
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :ensure_message, only: :create
def index
@copilot_threads = Current.account.copilot_threads
.where(user_id: Current.user.id)
.includes(:user)
.includes(:user, :assistant)
.order(created_at: :desc)
.page(permitted_params[:page] || 1)
.per(5)
end
def create
ActiveRecord::Base.transaction do
@copilot_thread = Current.account.copilot_threads.create!(
title: copilot_thread_params[:message][:content],
user: Current.user,
assistant: assistant
)
@copilot_thread.copilot_messages.create!(message_type: :user, message: copilot_thread_params[:message])
end
end
private
def ensure_message
return render_could_not_create_error('Message is required') if copilot_thread_params[:message][:content].blank?
end
def assistant
Current.account.captain_assistants.find(copilot_thread_params[:assistant_id])
end
def copilot_thread_params
params.permit(:assistant_id, message: [:content])
end
def permitted_params
params.permit(:page)
end
@@ -4,6 +4,29 @@ module Enterprise::Api::V1::Accounts::ConversationsController
before_action :set_assistant, only: [:copilot]
end
def copilot
# First try to get the user's preferred assistant from UI settings or from the request
assistant_id = copilot_params[:assistant_id] || current_user.ui_settings&.dig('preferred_captain_assistant_id')
# Find the assistant either by ID or from inbox
assistant = if assistant_id.present?
Captain::Assistant.find_by(id: assistant_id, account_id: Current.account.id)
else
@conversation.inbox.captain_assistant
end
return render json: { message: I18n.t('captain.copilot_error') } unless assistant
response = Captain::Copilot::ChatService.new(
assistant,
previous_history: copilot_params[:previous_history],
conversation_id: @conversation.display_id,
user_id: Current.user.id
).generate_response(copilot_params[:message])
render json: { message: response['response'] }
end
def inbox_assistant
assistant = @conversation.inbox.captain_assistant
@@ -21,6 +44,6 @@ module Enterprise::Api::V1::Accounts::ConversationsController
private
def copilot_params
params.permit(:previous_messages, :message, :assistant_id)
params.permit(:previous_history, :message, :assistant_id)
end
end
@@ -33,6 +33,6 @@ module Enterprise::SuperAdmin::AppConfigsController
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL]
CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL]
end
end
+22 -40
View File
@@ -1,29 +1,31 @@
module Captain::ChatHelper
def request_chat_completion
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{@messages}" }
log_chat_completion_request
response = @client.chat(
parameters: {
model: @model,
messages: @messages,
tools: @tool_registry&.registered_tools || [],
response_format: { type: 'json_object' }
response_format: { type: 'json_object' },
temperature: @assistant&.config&.[]('temperature').to_f || 1
}
)
handle_response(response)
rescue StandardError => e
Rails.logger.error { "[CAPTAIN][ChatCompletion] #{e}" }
raise e
end
private
def handle_response(response)
Rails.logger.info { "[CAPTAIN][ChatCompletion] #{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
JSON.parse(message['content'].strip)
message = JSON.parse(message['content'].strip)
persist_message(message, 'assistant')
message
end
end
@@ -39,18 +41,18 @@ module Captain::ChatHelper
arguments = JSON.parse(tool_call['function']['arguments'])
function_name = tool_call['function']['name']
tool_call_id = tool_call['id']
function_name = tool_call['function']['name']
arguments = JSON.parse(tool_call['function']['arguments'])
if @tool_registry.respond_to?(function_name)
execute_tool(function_name, arguments, tool_call_id)
else
process_invalid_tool_call(tool_call_id, function_name)
process_invalid_tool_call(function_name, tool_call_id)
end
end
def execute_tool(function_name, arguments, tool_call_id)
persist_message({ content: "Using tool #{function_name}", function_name: function_name }, 'assistant_thinking')
result = @tool_registry.send(function_name, arguments)
persist_message({ content: "Completed #{function_name} tool call", function_name: function_name }, 'assistant_thinking')
append_tool_response(result, tool_call_id)
end
@@ -61,6 +63,11 @@ module Captain::ChatHelper
}
end
def process_invalid_tool_call(function_name, tool_call_id)
persist_message({ content: 'Invalid tool call', function_name: function_name }, 'assistant_thinking')
append_tool_response('Tool not available', tool_call_id)
end
def append_tool_response(content, tool_call_id)
@messages << {
role: 'tool',
@@ -69,36 +76,11 @@ module Captain::ChatHelper
}
end
def publish_to_stream(response)
@stream_writer&.call(response)
end
def execute_tool_call(tool_call_id, function_name, arguments)
publish_to_stream(
{
response: { response: "Processing tool call #{function_name}" },
type: 'tool_calls_start'
}
)
result = @tool_registry.send(function_name, arguments.merge('user_id' => @user_id))
append_tool_response(result, tool_call_id)
publish_to_stream(
{
response: { response: "Received tool response #{function_name}" },
type: 'tool_response',
tool: function_name
}
)
end
def process_invalid_tool_call(tool_call_id, function_name)
append_tool_response('Tool not implemented', tool_call_id)
publish_to_stream(
{
response: { response: 'Tool not implemented' },
type: 'tool_error',
tool: function_name
}
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
"
)
end
end
@@ -48,6 +48,12 @@ messenger:
enabled: true
icon: 'icon-messenger-line'
config_key: 'facebook'
instagram:
name: 'Instagram'
description: 'Stay connected with your customers on Instagram'
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
whatsapp:
name: 'WhatsApp'
description: 'Manage your WhatsApp business interactions from Chatwoot.'
@@ -81,19 +87,19 @@ microsoft:
config_key: 'microsoft'
linear:
name: 'Linear'
description: 'Configuration for setting up Linear'
description: 'Configuration for setting up Linear Integration'
enabled: true
icon: 'icon-linear'
config_key: 'linear'
instagram:
name: 'Instagram'
description: 'Configuration for setting up Instagram'
slack:
name: 'Slack'
description: 'Configuration for setting up Slack Integration'
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
icon: 'icon-slack'
config_key: 'slack'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify'
description: 'Configuration for setting up Shopify Integration'
enabled: true
icon: 'icon-shopify'
config_key: 'shopify'
@@ -1,62 +0,0 @@
class Captain::ProcessCopilotMessageJob < ApplicationJob
queue_as :default
def perform(assistant_id:, message:, options: {})
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Starting job for assistant_id=#{assistant_id}")
ensure_assistant(assistant_id)
ensure_user(options[:user_id])
process_message(message, options)
end
private
def ensure_assistant(assistant_id)
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Finding assistant with id=#{assistant_id}")
@assistant = Captain::Assistant.find(assistant_id)
@account = @assistant.account
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Found assistant for account_id=#{@account.id}")
end
def ensure_user(user_id)
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Finding user with id=#{user_id}")
@user = @account.users.find(user_id)
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Found user #{@user.name}")
end
def process_message(message, options)
return unless @assistant
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Processing message with conversation_id=#{options[:conversation_id]}")
conversation = find_conversation(options[:conversation_id])
generate_chat_response(message, conversation, options[:previous_history])
end
def find_conversation(conversation_id)
return unless conversation_id
@account.conversations.find_by(display_id: conversation_id)
end
def generate_chat_response(message, conversation, previous_history)
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Generating chat response for conversation_id=#{conversation&.display_id}, user_id=#{@user.id}")
Captain::Copilot::ChatService.new(
@assistant,
previous_messages: previous_history || [],
stream_writer: ->(data) { broadcast_response(data) },
additional_info: {
language: @account.locale_english_name,
conversation_id: conversation&.display_id,
contact_id: conversation&.contact_id,
user_id: @user.id
}
).generate_response(message)
end
def broadcast_response(data)
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Broadcasting response to account_id=#{@account.id} user_id=#{@user.id}")
ActionCable.server.broadcast(
"copilot_#{@account.id}_#{@user.id}",
{ event: 'copilot.response', data: data }
)
end
end
@@ -1,7 +1,10 @@
class CaptainListener < BaseListener
include ::Events::Types
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
assistant = conversation.inbox.captain_assistant
return unless conversation.inbox.captain_active?
Captain::Llm::ContactNotesService.new(assistant, conversation).generate_and_update_notes if assistant.config['feature_memory'].present?
@@ -0,0 +1,11 @@
module Enterprise::ActionCableListener
include Events::Types
def copilot_message_created(event)
copilot_message = event.data[:copilot_message]
copilot_thread = copilot_message.copilot_thread
account = copilot_thread.account
user = copilot_thread.user
broadcast(account, [user.pubsub_token], COPILOT_MESSAGE_CREATED, copilot_message.push_event_data)
end
end
@@ -29,6 +29,7 @@ class Captain::Assistant < ApplicationRecord
has_many :inboxes,
through: :captain_inboxes
has_many :messages, as: :sender, dependent: :nullify
has_many :copilot_threads, dependent: :destroy_async
validates :name, presence: true
validates :description, presence: true
+38 -5
View File
@@ -4,24 +4,57 @@
#
# id :bigint not null, primary key
# message :jsonb not null
# message_type :string not null
# message_type :integer default("user")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# copilot_thread_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
# index_copilot_messages_on_account_id (account_id)
# index_copilot_messages_on_copilot_thread_id (copilot_thread_id)
# index_copilot_messages_on_user_id (user_id)
#
class CopilotMessage < ApplicationRecord
belongs_to :copilot_thread
belongs_to :user
belongs_to :account
validates :message_type, presence: true, inclusion: { in: %w[user assistant assistant_thinking] }
before_validation :ensure_account
enum message_type: { user: 0, assistant: 1, assistant_thinking: 2 }
validates :message_type, presence: true, inclusion: { in: message_types.keys }
validates :message, presence: true
validate :validate_message_attributes
after_create_commit :broadcast_message
def push_event_data
{
id: id,
message: message,
message_type: message_type,
created_at: created_at.to_i,
copilot_thread: copilot_thread.push_event_data
}
end
private
def ensure_account
self.account = copilot_thread.account
end
def broadcast_message
Rails.configuration.dispatcher.dispatch(COPILOT_MESSAGE_CREATED, Time.zone.now, copilot_message: self)
end
def validate_message_attributes
return if message.blank?
allowed_keys = %w[content reasoning function_name]
invalid_keys = message.keys - allowed_keys
errors.add(:message, "contains invalid attributes: #{invalid_keys.join(', ')}") if invalid_keys.any?
end
end
+34 -12
View File
@@ -2,25 +2,47 @@
#
# Table name: copilot_threads
#
# id :bigint not null, primary key
# title :string not null
# uuid :uuid not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# user_id :bigint not null
# id :bigint not null, primary key
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :integer
# user_id :bigint not null
#
# Indexes
#
# index_copilot_threads_on_account_id (account_id)
# index_copilot_threads_on_user_id (user_id)
# index_copilot_threads_on_uuid (uuid) UNIQUE
# index_copilot_threads_on_account_id (account_id)
# index_copilot_threads_on_assistant_id (assistant_id)
# index_copilot_threads_on_user_id (user_id)
#
class CopilotThread < ApplicationRecord
belongs_to :user
belongs_to :account
has_many :copilot_messages, dependent: :destroy
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :copilot_messages, dependent: :destroy_async
validates :title, presence: true
validates :uuid, presence: true, uniqueness: true
def push_event_data
{
id: id,
title: title,
created_at: created_at.to_i,
user: user.push_event_data,
account_id: account_id
}
end
def previous_history
copilot_messages
.where(message_type: %w[user assistant])
.order(created_at: :asc)
.map do |copilot_message|
{
content: copilot_message.message['content'],
role: copilot_message.message_type
}
end
end
end
@@ -6,7 +6,6 @@ module Enterprise::Concerns::User
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
has_many :copilot_threads, dependent: :destroy_async
has_many :copilot_messages, dependent: :destroy_async
end
def ensure_installation_pricing_plan_quantity
@@ -1,57 +1,66 @@
require 'openai'
class Captain::Copilot::ChatService < Llm::BaseOpenAiService
include Captain::ChatHelper
attr_reader :assistant, :language
attr_reader :assistant, :account, :user, :copilot_thread, :previous_history, :messages
def initialize(assistant, config = {})
def initialize(assistant, config)
super()
@assistant = assistant
@tool_registry = Captain::ToolRegistryService.new(@assistant)
@stream_writer = config[:stream_writer]
setup_additional_info(config)
@account = assistant.account
@user = nil
@copilot_thread = nil
@previous_history = []
setup_user(config)
setup_message_history(config)
register_tools
@messages = build_initial_messages(config)
end
def setup_additional_info(config)
additional_info = config[:additional_info] || {}
@language = additional_info[:language] || 'english'
@conversation_id = additional_info[:conversation_id]
@contact_id = additional_info[:contact_id]
@user_id = additional_info[:user_id]
Rails.logger.info("[Captain::Copilot::ChatService::User] user_id: #{@user_id}")
end
def build_initial_messages(config)
Rails.logger.info("[CAPTAIN][CopilotChatService] Building initial messages for conversation_id=#{@conversation_id}")
messages = [system_message]
messages += (config[:previous_messages] || [])
Rails.logger.info("[CAPTAIN][CopilotChatService] Added #{config[:previous_messages]&.length || 0} previous messages")
messages << current_viewing_history if @conversation_id
Rails.logger.info("[CAPTAIN][CopilotChatService] Total messages built: #{messages.length}")
messages
@messages = build_messages(config)
end
def generate_response(input)
@messages << { role: 'user', content: input } if input.present?
Rails.logger.info("[CAPTAIN][CopilotChatService] Initial Prompt: #{@messages}")
response = request_chat_completion
Rails.logger.info("[CAPTAIN][CopilotChatService] Incrementing response usage for #{@assistant.account.id}")
@assistant.account.increment_response_usage
publish_to_stream(
{
response: response,
type: 'final_response'
}
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Incrementing response usage for account #{@account.id}"
)
@account.increment_response_usage
response
end
private
def setup_user(config)
@user = @account.users.find_by(id: config[:user_id]) if config[:user_id].present?
end
def build_messages(config)
messages= [system_message]
messages << account_id_context
messages += @previous_history if @previous_history.present?
messages += current_viewing_history(config[:conversation_id]) if config[:conversation_id].present?
messages
end
def setup_message_history(config)
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Previous History: #{config[:previous_history]&.length || 0}, Language: #{config[:language]}"
)
@copilot_thread = @account.copilot_threads.find_by(id: config[:thread_id]) if config[:thread_id].present?
@previous_history = if @copilot_thread.present?
@copilot_thread.previous_history
else
config[:previous_history].presence || []
end
end
def register_tools
@tool_registry = Captain::ToolRegistryService.new(@assistant)
@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)
@@ -63,25 +72,41 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
end
def system_message
Rails.logger.info("[CAPTAIN][CopilotChatService] Generating system message for product=#{@assistant.config['product_name']} language=#{@language}")
{
role: 'system',
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'], @language, @assistant.account_id)
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'])
}
end
def current_viewing_history
Rails.logger.info("[CAPTAIN][CopilotChatService] Fetching viewing history for conversation_id=#{@conversation_id}")
return unless @conversation_id
def account_id_context
{
role: 'system',
content: "The current account id is #{@account.id}. The account is using #{@account.locale_english_name} as the language."
}
end
def current_viewing_history(conversation_id)
conversation = @account.conversations.find_by(display_id: conversation_id)
return [] unless conversation
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Setting viewing history for conversation_id=#{conversation_id}")
contact_id = conversation.contact_id
[{
role: 'system',
content: <<~HISTORY.strip
You are currently viewing the conversation with the user with the following details:
Conversation ID: #{@conversation_id}
Contact ID: #{@contact_id}
Account ID: #{@assistant.account.id}
You are currently viewing the conversation with the following details:
Conversation ID: #{conversation_id}
Contact ID: #{contact_id}
HISTORY
}
}]
end
def persist_message(message, message_type = 'assistant')
return if @copilot_thread.blank?
@copilot_thread.copilot_messages.create!(
message: message,
message_type: message_type
)
end
end
@@ -21,7 +21,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
private
def register_tools
@tool_registry = Captain::ToolRegistryService.new(@assistant)
@tool_registry = Captain::ToolRegistryService.new(@assistant, user: nil)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
end
@@ -31,4 +31,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.name, @assistant.config['product_name'], @assistant.config)
}
end
def persist_message(message, message_type = 'assistant')
# No need to implement
end
end
@@ -56,19 +56,18 @@ class Captain::Llm::SystemPromptsService
SYSTEM_PROMPT_MESSAGE
end
def copilot_response_generator(product_name, language, account_id)
def copilot_response_generator(product_name)
<<~SYSTEM_PROMPT_MESSAGE
[Identity]
You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions.
You should only provide information related to #{product_name} and must not address queries about other products or external events.
You are an assistant for the account with ID: #{account_id}
[Context]
Identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
[Response Guidelines]
- Use natural, polite, and conversational language that is clear and easy to follow. Keep sentences short and use simple words.
- Reply in the language the agent is using, if you're not able to detect the language, reply in #{language}.
- Reply in the language the agent is using, if you're not able to detect the language.
- Provide brief and relevant responsestypically one or two sentences unless a more detailed explanation is necessary.
- Do not use your own training data or assumptions to answer queries. Base responses strictly on the provided information.
- If the query is unclear, ask concise clarifying questions instead of making assumptions.
@@ -91,9 +90,6 @@ class Captain::Llm::SystemPromptsService
7. Write the response in multiple paragraphs and in markdown format.
8. DO NOT use headings in Markdown
9. Cite the sources if you used a tool to find the response.
10. Do not use your own training data or assumptions to answer queries. Base responses strictly on the provided information.
11. Always provide a reasoning for the response.
12. Always double the check the information with tools unless you are very sure of the information.
```json
{
@@ -46,7 +46,7 @@ class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseS
end
def active?
@assistant.account.hooks.find_by(app_id: 'linear').present?
@user.present? && @assistant.account.hooks.find_by(app_id: 'linear').present?
end
private
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/copilot_message', formats: [:json], resource: @copilot_message
@@ -1,8 +1,5 @@
json.payload do
json.array! @copilot_messages do |message|
json.id message.id
json.message message.message
json.message_type message.message_type
json.created_at message.created_at.to_i
json.partial! 'api/v1/models/captain/copilot_message', formats: [:json], resource: message
end
end
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/copilot_thread', formats: [:json], resource: @copilot_thread
@@ -1,12 +1,5 @@
json.payload do
json.array! @copilot_threads do |thread|
json.id thread.id
json.title thread.title
json.uuid thread.uuid
json.created_at thread.created_at.to_i
json.user do
json.id thread.user.id
json.name thread.user.name
end
json.partial! 'api/v1/models/captain/copilot_thread', resource: thread
end
end
@@ -0,0 +1,6 @@
json.id resource.id
json.message resource.message
json.message_type resource.message_type
json.created_at resource.created_at.to_i
json.copilot_thread resource.copilot_thread.push_event_data
json.account_id resource.account_id
@@ -0,0 +1,6 @@
json.id resource.id
json.title resource.title
json.created_at resource.created_at.to_i
json.user resource.user.push_event_data
json.assistant resource.assistant.push_event_data
json.account_id resource.account_id
+3
View File
@@ -54,4 +54,7 @@ module Events::Types
# agent events
AGENT_ADDED = 'agent.added'
AGENT_REMOVED = 'agent.removed'
# copilot events
COPILOT_MESSAGE_CREATED = 'copilot.message.created'
end
+1 -1
View File
@@ -1,6 +1,6 @@
class GlobalConfigService
def self.load(config_key, default_value)
config = ENV.fetch(config_key) { GlobalConfig.get(config_key)[config_key] }
config = GlobalConfig.get(config_key)[config_key]
return config if config.present?
# To support migrating existing instance relying on env variables
+2 -2
View File
@@ -32,8 +32,8 @@ class Integrations::Slack::HookBuilder
def fetch_access_token
client = Slack::Web::Client.new
slack_access = client.oauth_v2_access(
client_id: ENV.fetch('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'),
client_secret: ENV.fetch('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'),
client_id: GlobalConfigService.load('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'),
client_secret: GlobalConfigService.load('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'),
code: params[:code],
redirect_uri: Integrations::App.slack_integration_url
)
@@ -23,6 +23,10 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
end
describe '#omniauth_sucess' do
before do
GlobalConfig.clear_cache
end
it 'allows signup' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('test_not_preset@example.com')
@@ -4,12 +4,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
let(:account) { create(:account) }
let(:user) { create(:user, account: account, role: :administrator) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, user: user, account: account) }
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, account: account) }
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.uuid}/copilot_messages' do
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
context 'when it is an authenticated user' do
it 'returns all messages' do
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.uuid}/copilot_messages",
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.id}/copilot_messages",
headers: user.create_new_auth_token,
as: :json
@@ -20,9 +20,9 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
end
end
context 'when thread uuid is invalid' do
context 'when thread id is invalid' do
it 'returns not found error' do
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/invalid-uuid/copilot_messages",
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/999999999/copilot_messages",
headers: user.create_new_auth_token,
as: :json
@@ -30,4 +30,49 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
end
end
end
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
context 'when it is an authenticated user' do
it 'creates a new message' do
message_content = { 'content' => 'This is a test message' }
expect do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.id}/copilot_messages",
params: { message: message_content },
headers: user.create_new_auth_token,
as: :json
end.to change(CopilotMessage, :count).by(1)
expect(response).to have_http_status(:success)
expect(CopilotMessage.last.message).to eq(message_content)
expect(CopilotMessage.last.message_type).to eq('user')
expect(CopilotMessage.last.copilot_thread_id).to eq(copilot_thread.id)
end
end
context 'when thread does not exist' do
it 'returns not found error' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/999999999/copilot_messages",
params: { message: { text: 'Test message' } },
headers: user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when thread belongs to another user' do
let(:another_user) { create(:user, account: account) }
let(:another_thread) { create(:captain_copilot_thread, account: account, user: another_user) }
it 'returns not found error' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{another_thread.id}/copilot_messages",
params: { message: { text: 'Test message' } },
headers: user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
end
end
@@ -18,7 +18,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
end
end
context 'when it is an agent' do
context 'when it is an authenticated user' do
it 'fetches copilot threads for the current user' do
# Create threads for the current agent
create_list(:captain_copilot_thread, 3, account: account, user: agent)
@@ -47,4 +47,65 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
end
end
end
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:valid_params) { { message: { content: 'Hello, how can you help me?' }, assistant_id: assistant.id } }
context 'when it is an un-authenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: valid_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
context 'with invalid params' do
it 'returns error when message is blank' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: { message: { content: '' }, assistant_id: assistant.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response[:error]).to eq('Message is required')
end
it 'returns error when assistant_id is invalid' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: { message: { content: 'Hello' }, assistant_id: 0 },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'with valid params' do
it 'creates a new copilot thread with initial message' do
expect do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: valid_params,
headers: agent.create_new_auth_token,
as: :json
end.to change(CopilotThread, :count).by(1)
.and change(CopilotMessage, :count).by(1)
expect(response).to have_http_status(:success)
thread = CopilotThread.last
expect(thread.title).to eq(valid_params[:message][:content])
expect(thread.user_id).to eq(agent.id)
expect(thread.assistant_id).to eq(assistant.id)
message = thread.copilot_messages.last
expect(message.message_type).to eq('user')
expect(message.message).to eq(valid_params[:message].stringify_keys)
end
end
end
end
end
@@ -0,0 +1,24 @@
require 'rails_helper'
describe ActionCableListener do
describe '#copilot_message_created' do
let(:event_name) { :copilot_message_created }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
let(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread) }
let(:event) { Events::Base.new(event_name, Time.zone.now, copilot_message: copilot_message) }
let(:listener) { described_class.instance }
it 'broadcasts message to the user' do
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[user.pubsub_token],
'copilot.message.created',
copilot_message.push_event_data.merge(account_id: account.id)
)
listener.copilot_message_created(event)
end
end
end
@@ -0,0 +1,57 @@
require 'rails_helper'
describe CaptainListener do
let(:listener) { described_class.instance }
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account, config: { feature_memory: true, feature_faq: true }) }
describe '#conversation_resolved' do
let(:agent) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
let(:event_name) { :conversation_resolved }
let(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
before do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
end
context 'when feature_memory is enabled' do
before do
assistant.config['feature_memory'] = true
assistant.config['feature_faq'] = false
assistant.save!
end
it 'generates and updates notes' do
expect(Captain::Llm::ContactNotesService)
.to receive(:new)
.with(assistant, conversation)
.and_return(instance_double(Captain::Llm::ContactNotesService, generate_and_update_notes: nil))
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
listener.conversation_resolved(event)
end
end
context 'when feature_faq is enabled' do
before do
assistant.config['feature_faq'] = true
assistant.config['feature_memory'] = false
assistant.save!
end
it 'generates and deduplicates FAQs' do
expect(Captain::Llm::ConversationFaqService)
.to receive(:new)
.with(assistant, conversation)
.and_return(instance_double(Captain::Llm::ConversationFaqService, generate_and_deduplicate: false))
expect(Captain::Llm::ContactNotesService).not_to receive(:new)
listener.conversation_resolved(event)
end
end
end
end
@@ -0,0 +1,64 @@
require 'rails_helper'
RSpec.describe CopilotMessage, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:copilot_thread) }
it { is_expected.to belong_to(:account) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:message_type) }
it { is_expected.to validate_presence_of(:message) }
it { is_expected.to validate_inclusion_of(:message_type).in_array(described_class.message_types.keys) }
end
describe 'callbacks' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
describe '#ensure_account' do
it 'sets the account from the copilot thread before validation' do
message = build(:captain_copilot_message, copilot_thread: copilot_thread, account: nil)
message.valid?
expect(message.account).to eq(copilot_thread.account)
end
end
describe '#broadcast_message' do
it 'dispatches COPILOT_MESSAGE_CREATED event after create' do
message = build(:captain_copilot_message, copilot_thread: copilot_thread)
expect(Rails.configuration.dispatcher).to receive(:dispatch)
.with(COPILOT_MESSAGE_CREATED, anything, copilot_message: message)
message.save!
end
end
end
describe '#push_event_data' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
let(:message_content) { { 'content' => 'Test message' } }
let(:copilot_message) do
create(:captain_copilot_message,
copilot_thread: copilot_thread,
message_type: 'user',
message: message_content)
end
it 'returns the correct event data' do
event_data = copilot_message.push_event_data
expect(event_data[:id]).to eq(copilot_message.id)
expect(event_data[:message]).to eq(message_content)
expect(event_data[:message_type]).to eq('user')
expect(event_data[:created_at]).to eq(copilot_message.created_at.to_i)
expect(event_data[:copilot_thread]).to eq(copilot_thread.push_event_data)
end
end
end
@@ -0,0 +1,62 @@
require 'rails_helper'
RSpec.describe CopilotThread, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
it { is_expected.to have_many(:copilot_messages).dependent(:destroy_async) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:title) }
end
describe '#push_event_data' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant, title: 'Test Thread') }
it 'returns the correct event data' do
event_data = copilot_thread.push_event_data
expect(event_data[:id]).to eq(copilot_thread.id)
expect(event_data[:title]).to eq('Test Thread')
expect(event_data[:created_at]).to eq(copilot_thread.created_at.to_i)
expect(event_data[:user]).to eq(user.push_event_data)
expect(event_data[:account_id]).to eq(account.id)
end
end
describe '#previous_history' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
context 'when there are messages in the thread' do
before do
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'user', message: { 'content' => 'User message' })
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'assistant_thinking', message: { 'content' => 'Thinking...' })
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'assistant', message: { 'content' => 'Assistant message' })
end
it 'returns only user and assistant messages in chronological order' do
history = copilot_thread.previous_history
expect(history.length).to eq(2)
expect(history[0][:role]).to eq('user')
expect(history[0][:content]).to eq('User message')
expect(history[1][:role]).to eq('assistant')
expect(history[1][:content]).to eq('Assistant message')
end
end
context 'when there are no messages in the thread' do
it 'returns an empty array' do
expect(copilot_thread.previous_history).to eq([])
end
end
end
end
@@ -2,87 +2,245 @@ require 'rails_helper'
RSpec.describe Captain::Copilot::ChatService do
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
let(:captain_inbox_association) { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
let(:mock_captain_agent) { instance_double(Captain::Agent) }
let(:mock_captain_tool) { instance_double(Captain::Tool) }
let(:mock_openai_client) { instance_double(OpenAI::Client) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
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(
:captain_copilot_message, account: account, copilot_thread: copilot_thread
)
end
let(:previous_history) { [{ role: copilot_message.message_type, content: copilot_message.message['content'] }] }
let(:config) do
{ user_id: user.id, thread_id: copilot_thread.id, conversation_id: conversation.display_id }
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
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)
end
describe '#initialize' do
it 'sets default language to english when not specified' do
service = described_class.new(assistant, { previous_messages: [], conversation_history: '' })
expect(service.instance_variable_get(:@language)).to eq('english')
it 'sets up the service with correct instance variables' do
service = described_class.new(assistant, config)
expect(service.assistant).to eq(assistant)
expect(service.account).to eq(account)
expect(service.user).to eq(user)
expect(service.copilot_thread).to eq(copilot_thread)
expect(service.previous_history).to eq(previous_history)
end
it 'uses the specified language when provided' do
service = described_class.new(assistant, {
previous_messages: [],
conversation_history: '',
language: 'spanish'
})
expect(service.instance_variable_get(:@language)).to eq('spanish')
it 'builds messages with system message and account context' do
service = described_class.new(assistant, config)
messages = service.messages
expect(messages.first[:role]).to eq('system')
expect(messages.second[:role]).to eq('system')
expect(messages.second[:content]).to include(account.id.to_s)
end
end
describe '#generate_response' do
before do
allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
allow(mock_openai_client).to receive(:chat).and_return({ choices: [{ message: { content: '{ "result": "Hey" }' } }] }.with_indifferent_access)
let(:service) { described_class.new(assistant, config) }
allow(Captain::Agent).to receive(:new).and_return(mock_captain_agent)
allow(mock_captain_agent).to receive(:execute).and_return(true)
allow(mock_captain_agent).to receive(:register_tool).and_return(true)
it 'adds user input to messages when present' do
expect do
service.generate_response('Hello')
end.to(change { service.messages.count }.by(1))
allow(Captain::Tool).to receive(:new).and_return(mock_captain_tool)
allow(mock_captain_tool).to receive(:register_method).and_return(true)
allow(account).to receive(:increment_response_usage).and_return(true)
last_message = service.messages.last
expect(last_message[:role]).to eq('user')
expect(last_message[:content]).to eq('Hello')
end
it 'increments usage' do
described_class.new(assistant, { previous_messages: ['Hello'], conversation_history: 'Hi' }).generate_response('Hey')
expect(account).to have_received(:increment_response_usage).once
it 'does not add user input to messages when blank' do
expect do
service.generate_response('')
end.not_to(change { service.messages.count })
end
it 'includes language in system message' do
service = described_class.new(assistant, {
previous_messages: [],
conversation_history: '',
language: 'spanish'
})
it 'returns the response from request_chat_completion' do
expect(service.generate_response('Hello')).to eq({ 'content' => 'Hey' })
end
allow(Captain::Llm::SystemPromptsService).to receive(:copilot_response_generator)
.with(assistant.config['product_name'], 'spanish')
.and_return('Spanish system prompt')
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
system_message = service.send(:system_message)
expect(system_message[:content]).to eq('Spanish system prompt')
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
end
end
describe '#execute' do
before do
allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
allow(mock_openai_client).to receive(:chat).and_return({ choices: [{ message: { content: '{ "result": "Hey" }' } }] }.with_indifferent_access)
allow(Captain::Agent).to receive(:new).and_return(mock_captain_agent)
allow(mock_captain_agent).to receive(:execute).and_return(true)
allow(mock_captain_agent).to receive(:register_tool).and_return(true)
allow(Captain::Tool).to receive(:new).and_return(mock_captain_tool)
allow(mock_captain_tool).to receive(:register_method).and_return(true)
allow(account).to receive(:increment_response_usage).and_return(true)
describe '#setup_user' 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)
end
it 'increments usage' do
described_class.new(assistant, { previous_messages: ['Hello'], conversation_history: 'Hi' }).generate_response('Hey')
expect(account).to have_received(:increment_response_usage).once
it 'does not set user when user_id is not present in config' do
service = described_class.new(assistant, {})
expect(service.user).to be_nil
end
end
describe '#setup_message_history' do
context 'when thread_id is present' do
it 'finds the copilot thread and sets previous history from it' do
service = described_class.new(assistant, { thread_id: copilot_thread.id })
expect(service.copilot_thread).to eq(copilot_thread)
expect(service.previous_history).to eq previous_history
end
end
context 'when thread_id is not present' do
it 'uses previous_history from config if present' do
custom_history = [{ role: 'user', content: 'Custom message' }]
service = described_class.new(assistant, { previous_history: custom_history })
expect(service.copilot_thread).to be_nil
expect(service.previous_history).to eq(custom_history)
end
it 'uses empty array if previous_history is not present in config' do
service = described_class.new(assistant, {})
expect(service.copilot_thread).to be_nil
expect(service.previous_history).to eq([])
end
end
end
describe '#build_messages' do
it 'includes system message and account context' do
service = described_class.new(assistant, {})
messages = service.messages
expect(messages.first[:role]).to eq('system')
expect(messages.second[:role]).to eq('system')
expect(messages.second[:content]).to include(account.id.to_s)
end
it 'includes previous history when present' do
custom_history = [{ role: 'user', content: 'Custom message' }]
service = described_class.new(assistant, { previous_history: custom_history })
messages = service.messages
expect(messages.count).to be >= 3
expect(messages.any? { |m| m[:content] == 'Custom message' }).to be true
end
it 'includes current viewing history when conversation_id is present' do
service = described_class.new(assistant, { conversation_id: conversation.display_id })
messages = service.messages
viewing_history = messages.find { |m| m[:content].include?('You are currently viewing the conversation') }
expect(viewing_history).not_to be_nil
expect(viewing_history[:content]).to include(conversation.display_id.to_s)
expect(viewing_history[:content]).to include(contact.id.to_s)
end
end
describe '#persist_message' 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)
expect do
described_class.new(assistant, { thread_id: copilot_thread.id }).generate_response('Hello')
end.to change(CopilotMessage, :count).by(1)
last_message = CopilotMessage.last
expect(last_message.message_type).to eq('assistant')
expect(last_message.message['content']).to eq('Hey')
end
end
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))
end
end
end
end
@@ -2,8 +2,9 @@ require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant) }
let(:service) { described_class.new(assistant, user: user) }
describe '#name' do
it 'returns the correct service name' do
@@ -49,6 +49,10 @@ RSpec.describe Enterprise::ClearbitLookupService do
end
context 'when Clearbit is not enabled' do
before do
GlobalConfig.clear_cache
end
it 'returns nil without making an API call' do
with_modified_env CLEARBIT_API_KEY: nil do
expect(Net::HTTP).not_to receive(:start)
+1 -2
View File
@@ -1,9 +1,8 @@
FactoryBot.define do
factory :captain_copilot_message, class: 'CopilotMessage' do
account
user
copilot_thread { association :captain_copilot_thread }
message { { content: 'This is a test message' } }
message_type { 'user' }
message_type { 0 }
end
end
+1 -1
View File
@@ -3,6 +3,6 @@ FactoryBot.define do
account
user
title { Faker::Lorem.sentence }
uuid { SecureRandom.uuid }
assistant { create(:captain_assistant, account: account) }
end
end
@@ -0,0 +1,44 @@
require 'rails_helper'
RSpec.describe Internal::DeleteAccountsJob do
subject(:job) { described_class.perform_later }
let!(:account_marked_for_deletion) { create(:account) }
let!(:future_deletion_account) { create(:account) }
let!(:active_account) { create(:account) }
let(:account_deletion_service) { instance_double(AccountDeletionService, perform: true) }
before do
account_marked_for_deletion.update!(
custom_attributes: {
'marked_for_deletion_at' => 1.day.ago.iso8601,
'marked_for_deletion_reason' => 'user_requested'
}
)
future_deletion_account.update!(
custom_attributes: {
'marked_for_deletion_at' => 3.days.from_now.iso8601,
'marked_for_deletion_reason' => 'user_requested'
}
)
allow(AccountDeletionService).to receive(:new).and_return(account_deletion_service)
end
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
end
describe '#perform' do
it 'calls AccountDeletionService for accounts past deletion date' do
described_class.new.perform
expect(AccountDeletionService).to have_received(:new).with(account: account_marked_for_deletion)
expect(AccountDeletionService).not_to have_received(:new).with(account: future_deletion_account)
expect(AccountDeletionService).not_to have_received(:new).with(account: active_account)
expect(account_deletion_service).to have_received(:perform)
end
end
end
@@ -7,6 +7,10 @@ describe Webhooks::InstagramEventsJob do
stub_request(:post, /graph\.facebook\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {})
# Clear Redis mutex key to prevent lock conflicts in parallel tests
mutex_key = format(Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: 'Sender-id-1', ig_account_id: 'chatwoot-app-user-id-1')
Redis::Alfred.delete(mutex_key)
end
let!(:account) { create(:account) }
@@ -184,7 +188,11 @@ describe Webhooks::InstagramEventsJob do
before do
instagram_channel.update(access_token: 'valid_instagram_token')
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/Sender-id-1\?.*})
stub_request(:get, 'https://graph.instagram.com/v22.0/Sender-id-1')
.with(query: hash_including(
'fields' => 'name,username,profile_pic,follower_count,is_user_follow_business,is_business_follow_user,is_verified_user',
'access_token' => 'valid_instagram_token'
))
.to_return(
status: 200,
body: {
@@ -201,6 +209,7 @@ describe Webhooks::InstagramEventsJob do
)
end
# skip failing test
it 'creates incoming message with correct contact info in the instagram direct inbox' do
instagram_webhook.perform_now(message_events[:dm][:entry])
instagram_inbox.reload
@@ -268,7 +277,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'does not create contact or messages when Instagram API call fails' do
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
stub_request(:get, 'https://graph.instagram.com/v22.0/Sender-id-1')
.with(query: hash_including('access_token' => 'valid_instagram_token'))
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe AdministratorNotifications::AccountComplianceMailer do
let(:account) do
create(:account, custom_attributes: { 'marked_for_deletion_at' => 1.day.ago.iso8601, 'marked_for_deletion_reason' => 'user_requested' })
end
let(:soft_deleted_users) do
[
{ id: 1, original_email: 'user1@example.com' },
{ id: 2, original_email: 'user2@example.com' }
]
end
describe 'account_deleted' do
it 'has the right subject format' do
subject = described_class.new.send(:subject_for, account)
expect(subject).to eq("Account Deletion Notice for #{account.id} - #{account.name}")
end
it 'includes soft deleted users in meta when provided' do
mailer_instance = described_class.new
allow(mailer_instance).to receive(:params).and_return(
{ soft_deleted_users: soft_deleted_users }
)
meta = mailer_instance.send(:build_meta, account)
expect(meta['deleted_user_count']).to eq(2)
expect(meta['soft_deleted_users'].size).to eq(2)
expect(meta['soft_deleted_users'].first['user_id']).to eq('1')
expect(meta['soft_deleted_users'].first['user_email']).to eq('user1@example.com')
end
end
end
@@ -0,0 +1,63 @@
require 'rails_helper'
RSpec.describe AccountDeletionService do
let(:account) { create(:account) }
let(:mailer) { instance_double(ActionMailer::MessageDelivery, deliver_later: nil) }
describe '#perform' do
before do
allow(DeleteObjectJob).to receive(:perform_later)
allow(AdministratorNotifications::AccountComplianceMailer).to receive(:with).and_return(
instance_double(AdministratorNotifications::AccountComplianceMailer, account_deleted: mailer)
)
end
it 'enqueues DeleteObjectJob with the account' do
described_class.new(account: account).perform
expect(DeleteObjectJob).to have_received(:perform_later).with(account)
end
it 'sends a compliance notification email' do
described_class.new(account: account).perform
expect(AdministratorNotifications::AccountComplianceMailer).to have_received(:with) do |args|
expect(args[:account]).to eq(account)
expect(args).to include(:soft_deleted_users)
end
expect(mailer).to have_received(:deliver_later)
end
context 'when handling users' do
let(:user_with_one_account) { create(:user) }
let(:user_with_multiple_accounts) { create(:user) }
let(:second_account) { create(:account) }
before do
create(:account_user, user: user_with_one_account, account: account)
create(:account_user, user: user_with_multiple_accounts, account: account)
create(:account_user, user: user_with_multiple_accounts, account: second_account)
end
it 'soft deletes users who only belong to the deleted account' do
original_email = user_with_one_account.email
described_class.new(account: account).perform
# Reload the user to get the updated email
user_with_one_account.reload
expect(user_with_one_account.email).to eq("#{original_email}-deleted.com")
end
it 'does not modify emails for users belonging to multiple accounts' do
original_email = user_with_multiple_accounts.email
described_class.new(account: account).perform
# Reload the user to get the updated email
user_with_multiple_accounts.reload
expect(user_with_multiple_accounts.email).to eq(original_email)
end
end
end
end
@@ -56,6 +56,7 @@ RSpec.describe Conversations::MessageWindowService do
describe 'on Facebook channels' do
before do
stub_request(:post, /graph.facebook.com/)
GlobalConfig.clear_cache
end
let!(:facebook_channel) { create(:channel_facebook_page) }