Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e204c2aa4f | ||
|
|
1acc994d98 | ||
|
|
5185bbd34f | ||
|
|
58e5f7e4df | ||
|
|
7a5b170ff0 | ||
|
|
6c57dbb12d | ||
|
|
c174ee65e3 | ||
|
|
97b36a868e | ||
|
|
cb8821afbf | ||
|
|
cd71a6ec10 | ||
|
|
c1044a8b66 | ||
|
|
6e2a90113c | ||
|
|
9bd658137a | ||
|
|
f73c5ef0b8 | ||
|
|
f9fce5e2df | ||
|
|
c2d8e2ad77 | ||
|
|
03c0a7c62e | ||
|
|
d40a59f7fa |
+40
-40
@@ -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
|
||||
|
||||
@@ -7,8 +7,6 @@ gem 'rack-cors', '2.0.0', require: 'rack/cors'
|
||||
gem 'rails', '~> 7.1'
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem 'bootsnap', require: false
|
||||
# Turbo for SPA-like page acceleration
|
||||
gem 'turbo-rails'
|
||||
|
||||
##-- rails application helper gems --##
|
||||
gem 'acts-as-taggable-on'
|
||||
|
||||
@@ -815,9 +815,6 @@ GEM
|
||||
i18n
|
||||
timeout (0.4.3)
|
||||
trailblazer-option (0.1.2)
|
||||
turbo-rails (2.0.13)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
twilio-ruby (5.77.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
@@ -1007,7 +1004,6 @@ DEPENDENCIES
|
||||
telephone_number
|
||||
test-prof
|
||||
time_diff
|
||||
turbo-rails
|
||||
twilio-ruby (~> 5.66)
|
||||
twitty (~> 0.1.5)
|
||||
tzinfo-data
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController
|
||||
before_action :portal
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@query = params[:query]
|
||||
@articles = @portal.articles.published
|
||||
|
||||
@articles = @articles.search(search_params) if @query.present?
|
||||
|
||||
@articles = @articles.page(params[:page]).per(10) # Add pagination
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_params
|
||||
params.permit(:query, :locale, :sort, :status, :page)
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -63,6 +63,7 @@ export default {
|
||||
emits: ['contactPanelToggle'],
|
||||
setup() {
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const conversationPanelRef = ref(null);
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const closePopOutReplyBox = () => {
|
||||
@@ -98,6 +99,8 @@ export default {
|
||||
FEATURE_FLAGS.CHATWOOT_V4
|
||||
);
|
||||
|
||||
provide('contextMenuElementTarget', conversationPanelRef);
|
||||
|
||||
return {
|
||||
isEnterprise,
|
||||
isPopOutReplyBox,
|
||||
@@ -108,6 +111,7 @@ export default {
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
showNextBubbles,
|
||||
conversationPanelRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -541,6 +545,7 @@ export default {
|
||||
</div>
|
||||
<NextMessageList
|
||||
v-if="showNextBubbles"
|
||||
ref="conversationPanelRef"
|
||||
class="conversation-panel"
|
||||
:current-user-id="currentUserId"
|
||||
:first-unread-id="unReadMessages[0]?.id"
|
||||
@@ -572,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]">
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Rails from '@rails/ujs';
|
||||
import { Turbo } from '@hotwired/turbo-rails';
|
||||
import Turbolinks from 'turbolinks';
|
||||
import '../portal/application.scss';
|
||||
import { InitializationHelpers } from '../portal/portalHelpers';
|
||||
|
||||
Rails.start();
|
||||
Turbo.start();
|
||||
Turbolinks.start();
|
||||
|
||||
document.addEventListener('turbo:load', InitializationHelpers.onLoad);
|
||||
document.addEventListener('turbolinks:load', InitializationHelpers.onLoad);
|
||||
|
||||
@@ -90,29 +90,18 @@ export default {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
handleSubmit() {
|
||||
if (this.searchTerm.trim()) {
|
||||
const url = `/hc/${this.portalSlug}/${this.localeCode}/search?query=${encodeURIComponent(this.searchTerm)}`;
|
||||
window.location.href = url;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="closeSearch" class="relative w-full max-w-5xl my-4">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<PublicSearchInput
|
||||
:search-term="searchTerm"
|
||||
:search-placeholder="searchTranslations.searchPlaceholder"
|
||||
@update:search-term="onUpdateSearchTerm"
|
||||
@focus="openSearch"
|
||||
/>
|
||||
<button type="submit" class="sr-only">
|
||||
{{ searchTranslations.submit || 'Search' }}
|
||||
</button>
|
||||
</form>
|
||||
<PublicSearchInput
|
||||
:search-term="searchTerm"
|
||||
:search-placeholder="searchTranslations.searchPlaceholder"
|
||||
@update:search-term="onUpdateSearchTerm"
|
||||
@focus="openSearch"
|
||||
/>
|
||||
<div
|
||||
v-if="shouldShowSearchBox"
|
||||
class="absolute w-full top-14"
|
||||
|
||||
@@ -110,7 +110,7 @@ export default {
|
||||
<p class="py-1 px-3" :class="getClassName(element)">
|
||||
<a
|
||||
:href="`#${element.slug}`"
|
||||
data-turbo="false"
|
||||
data-turbolinks="false"
|
||||
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
|
||||
:class="elementTextStyles(element)"
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const getHeadingsfromTheArticle = () => {
|
||||
const slug = slugifyWithCounter(element.innerText);
|
||||
element.id = slug;
|
||||
element.className = 'scroll-mt-24 heading';
|
||||
element.innerHTML += `<a class="permalink text-slate-600 ml-3" href="#${slug}" title="${element.innerText}" data-turbo="false">#</a>`;
|
||||
element.innerHTML += `<a class="permalink text-slate-600 ml-3" href="#${slug}" title="${element.innerText}" data-turbolinks="false">#</a>`;
|
||||
rows.push({
|
||||
slug,
|
||||
title: element.innerText,
|
||||
@@ -144,7 +144,7 @@ export const InitializationHelpers = {
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = window.location.hash;
|
||||
a['data-turbo'] = false;
|
||||
a['data-turbolinks'] = false;
|
||||
a.click();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -18,7 +18,7 @@ By default, it renders:
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="initial-scale=1">
|
||||
<meta name= "turbo-cache-control" content= "no-cache">
|
||||
<meta name= "turbolinks-cache-control" content= "no-cache">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<%= vite_client_tag %>
|
||||
@@ -39,7 +39,7 @@ By default, it renders:
|
||||
<% else %>
|
||||
<title><%= @portal.page_title%></title>
|
||||
<% end %>
|
||||
|
||||
|
||||
<% if @portal.logo.present? %>
|
||||
<link rel="icon" href="<%= url_for(@portal.logo) %>">
|
||||
<% end %>
|
||||
|
||||
+30
@@ -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>
|
||||
@@ -1,184 +0,0 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
|
||||
<% end %>
|
||||
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
|
||||
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
|
||||
<div class="flex flex-row items-center gap-px mb-6">
|
||||
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
|
||||
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
<%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
|
||||
<%= render partial: 'icons/chevron-right' %>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
<%= I18n.t('public_portal.search.results') %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
|
||||
<%= I18n.t('public_portal.search.results_for', query: @query) %>
|
||||
</h1>
|
||||
|
||||
<!-- Search form for the results page -->
|
||||
<form class="w-full my-4" action="<%= request.path %>" method="GET" data-search-form>
|
||||
<input type="hidden" name="locale" value="<%= params[:locale] %>">
|
||||
<input type="text"
|
||||
name="query"
|
||||
value="<%= @query %>"
|
||||
placeholder="<%= I18n.t('public_portal.search.search_placeholder') %>"
|
||||
data-search-input
|
||||
autofocus
|
||||
class="w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<button type="submit" class="sr-only">
|
||||
<%= I18n.t('public_portal.search.submit') %>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="max-w-5xl mx-auto space-y-4 w-full px-4 md:px-8 py-4">
|
||||
<div class="flex items-center flex-row">
|
||||
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:underline hover:cursor-pointer leading-8 font-semibold"
|
||||
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
<%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
|
||||
<%= render partial: 'icons/chevron-right' %>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
<%= I18n.t('public_portal.search.results') %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
|
||||
<%= I18n.t('public_portal.search.results_for', query: @query) %>
|
||||
</h1>
|
||||
|
||||
<!-- Search form for the results page -->
|
||||
<form class="w-full my-4" action="<%= request.path %>" method="GET" data-search-form>
|
||||
<input type="hidden" name="locale" value="<%= params[:locale] %>">
|
||||
<input type="text"
|
||||
name="query"
|
||||
value="<%= @query %>"
|
||||
placeholder="<%= I18n.t('public_portal.search.search_placeholder') %>"
|
||||
data-search-input
|
||||
autofocus
|
||||
class="w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<button type="submit" class="sr-only">
|
||||
<%= I18n.t('public_portal.search.submit') %>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
document.addEventListener('turbo:load', function() {
|
||||
const searchInputs = document.querySelectorAll('[data-search-input]');
|
||||
|
||||
searchInputs.forEach(function(input) {
|
||||
let debounceTimer;
|
||||
|
||||
// Move cursor to end of input text after autofocus
|
||||
if (input.value.length > 0) {
|
||||
setTimeout(function() {
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
input.addEventListener('input', function() {
|
||||
const form = input.closest('[data-search-form]');
|
||||
const query = input.value.trim();
|
||||
|
||||
// Clear existing timer
|
||||
clearTimeout(debounceTimer);
|
||||
|
||||
// Only search if there's a query and it's different from current
|
||||
if (query.length > 0) {
|
||||
debounceTimer = setTimeout(function() {
|
||||
// Check if the query has actually changed
|
||||
const currentQuery = new URLSearchParams(window.location.search).get('query') || '';
|
||||
if (query !== currentQuery) {
|
||||
// Use Turbo.visit for SPA-like navigation
|
||||
const formData = new FormData(form);
|
||||
const searchParams = new URLSearchParams(formData);
|
||||
const url = form.action + '?' + searchParams.toString();
|
||||
Turbo.visit(url);
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
}
|
||||
});
|
||||
|
||||
// Handle manual form submission
|
||||
input.closest('[data-search-form]').addEventListener('submit', function(e) {
|
||||
clearTimeout(debounceTimer);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
|
||||
<div class="w-full flex flex-col gap-6 flex-grow">
|
||||
<% if @articles.empty? %>
|
||||
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
|
||||
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.search.no_results', query: @query) %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
<%= I18n.t('public_portal.search.found_results', count: @articles.size) %>
|
||||
</p>
|
||||
|
||||
<% @articles.each do |article| %>
|
||||
<div class="border border-solid border-slate-100 dark:border-slate-800 rounded-lg">
|
||||
<a class="p-4 text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
|
||||
href="<%= generate_article_link(@portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold">
|
||||
<%= article.title %>
|
||||
</h3>
|
||||
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-2 break-all">
|
||||
<%= render_category_content(article.content) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<% if article.category.present? %>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
|
||||
<%= article.category.name %>
|
||||
</span>
|
||||
<span class="text-slate-600 dark:text-slate-400">•</span>
|
||||
<% end %>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
|
||||
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Pagination -->
|
||||
<% if @articles.respond_to?(:total_pages) && @articles.total_pages > 1 %>
|
||||
<div class="flex justify-center mt-6">
|
||||
<nav class="inline-flex">
|
||||
<% if @articles.prev_page %>
|
||||
<a href="<%= url_for(page: @articles.prev_page) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-l-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<%= I18n.t('public_portal.common.previous') %>
|
||||
</a>
|
||||
<% end %>
|
||||
|
||||
<% if @articles.next_page %>
|
||||
<a href="<%= url_for(page: @articles.next_page) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-r-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<%= I18n.t('public_portal.common.next') %>
|
||||
</a>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 |
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -254,13 +262,6 @@ en:
|
||||
empty_placeholder: No results found.
|
||||
loading_placeholder: Searching...
|
||||
results_title: Search results
|
||||
results: Search Results
|
||||
results_for: "Search Results for '%{query}'"
|
||||
no_results: "No results found for '%{query}'"
|
||||
found_results:
|
||||
one: Found 1 result
|
||||
other: "Found %{count} results"
|
||||
submit: Search
|
||||
toc_header: 'On this page'
|
||||
hero:
|
||||
sub_title: Search for the articles here or browse the categories below.
|
||||
|
||||
@@ -240,13 +240,6 @@ es:
|
||||
empty_placeholder: No se encontraron resultados.
|
||||
loading_placeholder: Buscando...
|
||||
results_title: Buscar resultados
|
||||
results: Resultados de búsqueda
|
||||
results_for: "Resultados de búsqueda para '%{query}'"
|
||||
no_results: "No se encontraron resultados para '%{query}'"
|
||||
found_results:
|
||||
one: Se encontró 1 resultado
|
||||
other: "Se encontraron %{count} resultados"
|
||||
submit: Buscar
|
||||
toc_header: 'En esta página'
|
||||
hero:
|
||||
sub_title: Busque aquí los artículos o busque las categorías de abajo.
|
||||
|
||||
@@ -442,7 +442,6 @@ Rails.application.routes.draw do
|
||||
get 'hc/:slug', to: 'public/api/v1/portals#show'
|
||||
get 'hc/:slug/sitemap.xml', to: 'public/api/v1/portals#sitemap'
|
||||
get 'hc/:slug/:locale', to: 'public/api/v1/portals#show'
|
||||
get 'hc/:slug/:locale/search', to: 'public/api/v1/portals/search#index', as: :portal_search
|
||||
get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index'
|
||||
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
|
||||
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@copilot_thread = Current.account.copilot_threads.create!(
|
||||
title: copilot_thread_params[:message],
|
||||
title: copilot_thread_params[:message][:content],
|
||||
user: Current.user,
|
||||
assistant: assistant
|
||||
)
|
||||
@@ -25,7 +25,7 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
|
||||
private
|
||||
|
||||
def ensure_message
|
||||
return render_could_not_create_error('Message is required') if copilot_thread_params[:message].blank?
|
||||
return render_could_not_create_error('Message is required') if copilot_thread_params[:message][:content].blank?
|
||||
end
|
||||
|
||||
def assistant
|
||||
@@ -33,7 +33,7 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
|
||||
end
|
||||
|
||||
def copilot_thread_params
|
||||
params.permit(:message, :assistant_id)
|
||||
params.permit(:assistant_id, message: [:content])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -19,9 +19,9 @@ module Enterprise::Api::V1::Accounts::ConversationsController
|
||||
|
||||
response = Captain::Copilot::ChatService.new(
|
||||
assistant,
|
||||
previous_messages: copilot_params[:previous_messages],
|
||||
conversation_history: @conversation.to_llm_text,
|
||||
language: @conversation.account.locale_english_name
|
||||
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'] }
|
||||
@@ -44,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module Captain::ChatHelper
|
||||
def request_chat_completion
|
||||
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{@messages}" }
|
||||
log_chat_completion_request
|
||||
|
||||
response = @client.chat(
|
||||
parameters: {
|
||||
@@ -15,13 +15,17 @@ module Captain::ChatHelper
|
||||
handle_response(response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_response(response)
|
||||
Rails.logger.debug { "[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
|
||||
|
||||
@@ -41,12 +45,14 @@ module Captain::ChatHelper
|
||||
if @tool_registry.respond_to?(function_name)
|
||||
execute_tool(function_name, arguments, tool_call_id)
|
||||
else
|
||||
process_invalid_tool_call(tool_call_id)
|
||||
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
|
||||
|
||||
@@ -57,7 +63,8 @@ module Captain::ChatHelper
|
||||
}
|
||||
end
|
||||
|
||||
def process_invalid_tool_call(tool_call_id)
|
||||
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
|
||||
|
||||
@@ -68,4 +75,12 @@ module Captain::ChatHelper
|
||||
content: content
|
||||
}
|
||||
end
|
||||
|
||||
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'
|
||||
|
||||
@@ -25,6 +25,7 @@ class CopilotMessage < ApplicationRecord
|
||||
|
||||
validates :message_type, presence: true, inclusion: { in: message_types.keys }
|
||||
validates :message, presence: true
|
||||
validate :validate_message_attributes
|
||||
|
||||
after_create_commit :broadcast_message
|
||||
|
||||
@@ -47,4 +48,13 @@ class CopilotMessage < ApplicationRecord
|
||||
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
|
||||
|
||||
@@ -40,7 +40,7 @@ class CopilotThread < ApplicationRecord
|
||||
.order(created_at: :asc)
|
||||
.map do |copilot_message|
|
||||
{
|
||||
content: copilot_message.message,
|
||||
content: copilot_message.message['content'],
|
||||
role: copilot_message.message_type
|
||||
}
|
||||
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
|
||||
|
||||
@@ -3,49 +3,110 @@ require 'openai'
|
||||
class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
include Captain::ChatHelper
|
||||
|
||||
attr_reader :assistant, :account, :user, :copilot_thread, :previous_history, :messages
|
||||
|
||||
def initialize(assistant, config)
|
||||
super()
|
||||
|
||||
@assistant = assistant
|
||||
@conversation_history = config[:conversation_history]
|
||||
@previous_messages = config[:previous_messages] || []
|
||||
@language = config[:language] || 'english'
|
||||
|
||||
@account = assistant.account
|
||||
@user = nil
|
||||
@copilot_thread = nil
|
||||
@previous_history = []
|
||||
setup_user(config)
|
||||
setup_message_history(config)
|
||||
register_tools
|
||||
@messages = [system_message, conversation_history_context] + @previous_messages
|
||||
@response = ''
|
||||
@messages = build_messages(config)
|
||||
end
|
||||
|
||||
def generate_response(input)
|
||||
@messages << { role: 'user', content: input } if input.present?
|
||||
response = request_chat_completion
|
||||
Rails.logger.info("[CAPTAIN][CopilotChatService] Incrementing response usage for #{@assistant.account.id}")
|
||||
@assistant.account.increment_response_usage
|
||||
|
||||
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)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::GetConversationService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchArticlesService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchContactsService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchConversationsService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchLinearIssuesService)
|
||||
end
|
||||
|
||||
def system_message
|
||||
{
|
||||
role: 'system',
|
||||
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'], @language)
|
||||
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'])
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_history_context
|
||||
def account_id_context
|
||||
{
|
||||
role: 'system',
|
||||
content: "
|
||||
Message History with the user is below:
|
||||
#{@conversation_history}
|
||||
"
|
||||
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 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,18 +56,18 @@ class Captain::Llm::SystemPromptsService
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def copilot_response_generator(product_name, language)
|
||||
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.
|
||||
|
||||
[Context]
|
||||
You will be provided with the message history between the support agent and the customer. Use this context to understand the conversation flow, identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
|
||||
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 responses—typically 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
+1
-1
@@ -39,7 +39,6 @@
|
||||
"@formkit/vue": "^1.6.7",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@hotwired/turbo-rails": "^8.0.13",
|
||||
"@iconify-json/material-symbols": "^1.2.10",
|
||||
"@june-so/analytics-next": "^2.0.0",
|
||||
"@lk77/vue3-color": "^3.0.6",
|
||||
@@ -85,6 +84,7 @@
|
||||
"snakecase-keys": "^8.0.1",
|
||||
"timezone-phone-codes": "^0.0.2",
|
||||
"tinykeys": "^3.0.0",
|
||||
"turbolinks": "^5.2.0",
|
||||
"urlpattern-polyfill": "^10.0.0",
|
||||
"video.js": "7.18.1",
|
||||
"videojs-record": "4.5.0",
|
||||
|
||||
Generated
+8
-22
@@ -37,9 +37,6 @@ importers:
|
||||
'@highlightjs/vue-plugin':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(highlight.js@11.10.0)(vue@3.5.12(typescript@5.6.2))
|
||||
'@hotwired/turbo-rails':
|
||||
specifier: ^8.0.13
|
||||
version: 8.0.13
|
||||
'@iconify-json/material-symbols':
|
||||
specifier: ^1.2.10
|
||||
version: 1.2.10
|
||||
@@ -175,6 +172,9 @@ importers:
|
||||
tinykeys:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
turbolinks:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0
|
||||
urlpattern-polyfill:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
@@ -865,13 +865,6 @@ packages:
|
||||
'@histoire/vendors@0.17.17':
|
||||
resolution: {integrity: sha512-QZvmffdoJlLuYftPIkOU5Q2FPAdG2JjMuQ5jF7NmEl0n1XnmbMqtRkdYTZ4eF6CO1KLZ0Zyf6gBQvoT1uWNcjA==}
|
||||
|
||||
'@hotwired/turbo-rails@8.0.13':
|
||||
resolution: {integrity: sha512-6SCnnOSzhtaJ0pNkAjncZxjtKsK3sP/vPEkCnTXBXSHkr+vF7DTZkOlwjhms1DbbQNTsjCsBoKvzSMbh/omSCQ==}
|
||||
|
||||
'@hotwired/turbo@8.0.13':
|
||||
resolution: {integrity: sha512-M7qXUqcGab6G5PKOiwhgbByTtrPgKPFCTMNQ52QhzUEXEqmp0/ApEguUesh/FPiUjrmFec+3lq98KsWnYY2C7g==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
'@humanwhocodes/config-array@0.11.14':
|
||||
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
|
||||
engines: {node: '>=10.10.0'}
|
||||
@@ -1043,9 +1036,6 @@ packages:
|
||||
'@rails/actioncable@6.1.3':
|
||||
resolution: {integrity: sha512-m02524MR9cTnUNfGz39Lkx9jVvuL0tle4O7YgvouJ7H83FILxzG1nQ5jw8pAjLAr9XQGu+P1sY4SKE3zyhCNjw==}
|
||||
|
||||
'@rails/actioncable@7.2.201':
|
||||
resolution: {integrity: sha512-wsTdWoZ5EfG5k3t7ORdyQF0ZmDEgN4aVPCanHAiNEwCROqibSZMXXmCbH7IDJUVri4FOeAVwwbPINI7HVHPKBw==}
|
||||
|
||||
'@rails/ujs@7.1.400':
|
||||
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
|
||||
|
||||
@@ -4720,6 +4710,9 @@ packages:
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
turbolinks@5.2.0:
|
||||
resolution: {integrity: sha512-pMiez3tyBo6uRHFNNZoYMmrES/IaGgMhQQM+VFF36keryjb5ms0XkVpmKHkfW/4Vy96qiGW3K9bz0tF5sK9bBw==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -5730,13 +5723,6 @@ snapshots:
|
||||
|
||||
'@histoire/vendors@0.17.17': {}
|
||||
|
||||
'@hotwired/turbo-rails@8.0.13':
|
||||
dependencies:
|
||||
'@hotwired/turbo': 8.0.13
|
||||
'@rails/actioncable': 7.2.201
|
||||
|
||||
'@hotwired/turbo@8.0.13': {}
|
||||
|
||||
'@humanwhocodes/config-array@0.11.14':
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
@@ -5956,8 +5942,6 @@ snapshots:
|
||||
|
||||
'@rails/actioncable@6.1.3': {}
|
||||
|
||||
'@rails/actioncable@7.2.201': {}
|
||||
|
||||
'@rails/ujs@7.1.400': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.40.2':
|
||||
@@ -10216,6 +10200,8 @@ snapshots:
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
turbolinks@5.2.0: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
@@ -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')
|
||||
|
||||
+5
-5
@@ -50,7 +50,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id } }
|
||||
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
|
||||
@@ -66,7 +66,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request 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: '', assistant_id: assistant.id },
|
||||
params: { message: { content: '' }, assistant_id: assistant.id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
@@ -76,7 +76,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
||||
|
||||
it 'returns error when assistant_id is invalid' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
params: { message: 'Hello', assistant_id: 0 },
|
||||
params: { message: { content: 'Hello' }, assistant_id: 0 },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
@@ -97,13 +97,13 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
thread = CopilotThread.last
|
||||
expect(thread.title).to eq(valid_params[:message])
|
||||
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])
|
||||
expect(message.message).to eq(valid_params[:message].stringify_keys)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -47,9 +47,9 @@ RSpec.describe CopilotThread, type: :model do
|
||||
|
||||
expect(history.length).to eq(2)
|
||||
expect(history[0][:role]).to eq('user')
|
||||
expect(history[0][:content]).to eq({ 'content' => 'User message' })
|
||||
expect(history[0][:content]).to eq('User message')
|
||||
expect(history[1][:role]).to eq('assistant')
|
||||
expect(history[1][:content]).to eq({ 'content' => 'Assistant message' })
|
||||
expect(history[1][:content]).to eq('Assistant message')
|
||||
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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -203,24 +203,4 @@ describe ActionCableListener do
|
||||
listener.conversation_updated(event)
|
||||
end
|
||||
end
|
||||
|
||||
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) }
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
listener.copilot_message_created(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user