Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e204c2aa4f | ||
|
|
1acc994d98 | ||
|
|
5185bbd34f | ||
|
|
58e5f7e4df | ||
|
|
7a5b170ff0 | ||
|
|
6c57dbb12d | ||
|
|
c174ee65e3 | ||
|
|
97b36a868e | ||
|
|
cb8821afbf | ||
|
|
cd71a6ec10 | ||
|
|
c1044a8b66 | ||
|
|
6e2a90113c | ||
|
|
9bd658137a | ||
|
|
f73c5ef0b8 | ||
|
|
f9fce5e2df | ||
|
|
c2d8e2ad77 | ||
|
|
03c0a7c62e | ||
|
|
d40a59f7fa | ||
|
|
72c5671e09 | ||
|
|
8c0885e1d2 | ||
|
|
e92f72b318 | ||
|
|
ea04a292ed | ||
|
|
deeb6d4e3f | ||
|
|
a3bf753f4c | ||
|
|
459f225559 | ||
|
|
99de5f4257 | ||
|
|
4b417ce9e7 | ||
|
|
8697a30dc5 | ||
|
|
8565341682 | ||
|
|
9bd7daeaf8 |
+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
|
||||
|
||||
@@ -33,6 +33,8 @@ gem 'liquid'
|
||||
gem 'commonmarker'
|
||||
# Validate Data against JSON Schema
|
||||
gem 'json_schemer'
|
||||
# used in swagger build
|
||||
gem 'json_refs'
|
||||
# Rack middleware for blocking & throttling abusive requests
|
||||
gem 'rack-attack', '>= 6.7.0'
|
||||
# a utility tool for streaming, flexible and safe downloading of remote files
|
||||
@@ -196,9 +198,6 @@ group :development do
|
||||
gem 'scss_lint', require: false
|
||||
gem 'web-console', '>= 4.2.1'
|
||||
|
||||
# used in swagger build
|
||||
gem 'json_refs'
|
||||
|
||||
# When we want to squash migrations
|
||||
gem 'squasher'
|
||||
|
||||
|
||||
@@ -152,11 +152,13 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
end
|
||||
|
||||
def message_already_exists?
|
||||
cw_message = conversation.messages.where(
|
||||
source_id: @messaging[:message][:mid]
|
||||
).first
|
||||
find_message_by_source_id(@messaging[:message][:mid]).present?
|
||||
end
|
||||
|
||||
cw_message.present?
|
||||
def find_message_by_source_id(source_id)
|
||||
return unless source_id
|
||||
|
||||
@message = Message.find_by(source_id: source_id)
|
||||
end
|
||||
|
||||
def all_unsupported_files?
|
||||
|
||||
@@ -68,7 +68,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
|
||||
def article_params
|
||||
params.require(:article).permit(
|
||||
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
|
||||
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
|
||||
:locale, meta: [:title,
|
||||
:description,
|
||||
{ tags: [] }]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class Platform::Api::V1::UsersController < PlatformController
|
||||
# ref: https://stackoverflow.com/a/45190318/939299
|
||||
# set resource is called for other actions already in platform controller
|
||||
# we want to add login to that chain as well
|
||||
before_action(only: [:login]) { set_resource }
|
||||
before_action(only: [:login]) { validate_platform_app_permissible }
|
||||
# we want to add login and token to that chain as well
|
||||
before_action(only: [:login, :token]) { set_resource }
|
||||
before_action(only: [:login, :token]) { validate_platform_app_permissible }
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -18,6 +18,8 @@ class Platform::Api::V1::UsersController < PlatformController
|
||||
render json: { url: @resource.generate_sso_link }
|
||||
end
|
||||
|
||||
def token; end
|
||||
|
||||
def update
|
||||
@resource.assign_attributes(user_update_params)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+23
-1
@@ -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"
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -367,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."
|
||||
},
|
||||
@@ -410,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'),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -31,7 +31,11 @@ class Crm::Leadsquared::LeadFinderService
|
||||
def find_by_phone_number(contact)
|
||||
return if contact.phone_number.blank?
|
||||
|
||||
search_by_field(contact.phone_number)
|
||||
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
|
||||
|
||||
return if lead_data.blank? || lead_data['Mobile'].nil?
|
||||
|
||||
search_by_field(lead_data['Mobile'])
|
||||
end
|
||||
|
||||
def search_by_field(value)
|
||||
|
||||
@@ -20,11 +20,29 @@ class Crm::Leadsquared::Mappers::ContactMapper
|
||||
'FirstName' => contact.name.presence,
|
||||
'LastName' => contact.last_name.presence,
|
||||
'EmailAddress' => contact.email.presence,
|
||||
'Mobile' => contact.phone_number.presence,
|
||||
'Mobile' => formatted_phone_number,
|
||||
'Source' => brand_name
|
||||
}.compact
|
||||
end
|
||||
|
||||
def formatted_phone_number
|
||||
# it seems like leadsquared needs a different phone number format
|
||||
# it's not documented anywhere, so don't bother trying to look up online
|
||||
# After some trial and error, I figured out the format, its +<country_code>-<national_number>
|
||||
return nil if contact.phone_number.blank?
|
||||
|
||||
parsed = TelephoneNumber.parse(contact.phone_number)
|
||||
return contact.phone_number unless parsed.valid?
|
||||
|
||||
country_code = parsed.country.country_code
|
||||
e164 = parsed.e164_number
|
||||
e164 = e164.sub(/^\+/, '')
|
||||
|
||||
national_number = e164.sub(/^#{Regexp.escape(country_code)}/, '')
|
||||
|
||||
"+#{country_code}-#{national_number}"
|
||||
end
|
||||
|
||||
def brand_name
|
||||
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
|
||||
end
|
||||
|
||||
@@ -27,7 +27,7 @@ class MessageTemplates::HookExecutionService
|
||||
return false unless message.incoming?
|
||||
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
|
||||
# ensures better UX by not interrupting active conversations at the end of business hours
|
||||
return false if conversation.messages.outgoing.exists?(['created_at > ?', 5.minutes.ago])
|
||||
return false if conversation.messages.outgoing.where(private: false).exists?(['created_at > ?', 5.minutes.ago])
|
||||
|
||||
inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present?
|
||||
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>
|
||||
@@ -0,0 +1,9 @@
|
||||
json.access_token @resource.access_token.token
|
||||
json.expiry nil
|
||||
json.user do
|
||||
json.id @resource.id
|
||||
json.name @resource.name
|
||||
json.display_name @resource.display_name
|
||||
json.email @resource.email
|
||||
json.pubsub_token @resource.pubsub_token
|
||||
end
|
||||
@@ -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 |
@@ -14,6 +14,10 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) || Rails.env.development?
|
||||
resource '*', headers: :any, methods: :any, expose: %w[access-token client uid expiry]
|
||||
end
|
||||
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_API_CORS', false))
|
||||
resource '/api/*', headers: :any, methods: :any, expose: %w[access-token client uid expiry]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
+3
-2
@@ -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
|
||||
@@ -396,6 +396,7 @@ Rails.application.routes.draw do
|
||||
resources :users, only: [:create, :show, :update, :destroy] do
|
||||
member do
|
||||
get :login
|
||||
post :token
|
||||
end
|
||||
end
|
||||
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
||||
|
||||
@@ -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
@@ -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|
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
## Chatwoot Developer Documentation
|
||||
|
||||
Welcome to the official Chatwoot developer documentation. This guide contains everything you need to know about Chatwoot APIs and build custom flows on top of Chatwoot APIs.
|
||||
|
||||
### 👩💻 Development
|
||||
|
||||
Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command
|
||||
|
||||
```
|
||||
npm i -g mintlify
|
||||
```
|
||||
|
||||
Run the following command at the root of your documentation (where mint.json is)
|
||||
|
||||
```
|
||||
mintlify dev
|
||||
```
|
||||
|
||||
### 😎 Publishing Changes
|
||||
|
||||
Changes will be deployed to production automatically after pushing to the default branch.
|
||||
|
||||
You can also preview changes using PRs, which generates a preview link of the docs.
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
|
||||
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"name": "Chatwoot Developer Docs",
|
||||
"description": "Official developer documentation for Chatwoot - the open-source customer support platform. Learn about our APIs, integrations, and development guidelines.",
|
||||
"logo": {
|
||||
"dark": "/logo/dark.png",
|
||||
"light": "/logo/light.png"
|
||||
},
|
||||
"favicon": "/favicon.png",
|
||||
"colors": {
|
||||
"primary": "#0069ED",
|
||||
"light": "#4D9CFF",
|
||||
"dark": "#0050B4"
|
||||
},
|
||||
"fonts": {
|
||||
"heading": {
|
||||
"family": "Haskoy",
|
||||
"weight": 500,
|
||||
"source": "https://d1j5ayohogpcd2.cloudfront.net/fonts/haskoy/Haskoy-Medium.woff2",
|
||||
"format": "woff2"
|
||||
},
|
||||
"body": {
|
||||
"family": "Haskoy",
|
||||
"weight": 400,
|
||||
"source": "https://d1j5ayohogpcd2.cloudfront.net/fonts/haskoy/Haskoy-Regular.woff2",
|
||||
"format": "woff2"
|
||||
}
|
||||
},
|
||||
"theme": "maple",
|
||||
"navigation": {
|
||||
"groups": [
|
||||
{
|
||||
"group": "API Reference",
|
||||
"pages": [
|
||||
"introduction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Application API",
|
||||
"description": "APIs for managing application aspects of Chatwoot",
|
||||
"includeTags": [
|
||||
"Agents",
|
||||
"Automation Rule",
|
||||
"Account AgentBots",
|
||||
"Canned Responses",
|
||||
"Contact Labels",
|
||||
"Contacts",
|
||||
"Conversation Assignments",
|
||||
"Conversation Labels",
|
||||
"Conversations",
|
||||
"Custom Attributes",
|
||||
"Custom Filters",
|
||||
"Help Center",
|
||||
"Inboxes",
|
||||
"Integrations",
|
||||
"Messages",
|
||||
"Profile",
|
||||
"Reports",
|
||||
"Teams",
|
||||
"Webhooks"
|
||||
],
|
||||
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/application_swagger.json"
|
||||
},
|
||||
{
|
||||
"group": "Platform API",
|
||||
"description": "APIs for managing platform aspects of Chatwoot",
|
||||
"includeTags": [
|
||||
"Accounts",
|
||||
"Account Users",
|
||||
"AgentBots",
|
||||
"Users"
|
||||
],
|
||||
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/platform_swagger.json"
|
||||
},
|
||||
{
|
||||
"group": "Client API",
|
||||
"description": "APIs for client applications",
|
||||
"includeTags": [
|
||||
"Contacts API",
|
||||
"Conversations API",
|
||||
"Messages API"
|
||||
],
|
||||
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/client_swagger.json"
|
||||
},
|
||||
{
|
||||
"group": "Other APIs",
|
||||
"description": "Other Chatwoot APIs",
|
||||
"includeTags": [
|
||||
"CSAT Survey Page"
|
||||
],
|
||||
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/other_swagger.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"footerSocials": {
|
||||
"twitter": "https://twitter.com/chatwootapp",
|
||||
"github": "https://github.com/chatwoot",
|
||||
"linkedin": "https://www.linkedin.com/company/chatwoot"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Introduction to Chatwoot APIs
|
||||
description: Learn how to use Chatwoot APIs to build integrations, customize chat experiences, and manage your installation.
|
||||
sidebarTitle: Introduction
|
||||
---
|
||||
|
||||
Welcome to the Chatwoot API documentation. Whether you're building custom workflows for your support team, integrating Chatwoot into your product, or managing users across installations, our APIs provide the flexibility and power to help you do more with Chatwoot.
|
||||
|
||||
Chatwoot provides three categories of APIs, each designed with a specific use case in mind:
|
||||
|
||||
- **Application APIs** – For account-level automation and agent-facing integrations.
|
||||
- **Client APIs** – For building custom chat interfaces for end-users
|
||||
- **Platform APIs** – For managing and administering installations at scale
|
||||
|
||||
---
|
||||
|
||||
## Application APIs
|
||||
|
||||
Application APIs are designed for interacting with a Chatwoot account from an agent/admin perspective. Use them to build internal tools, automate workflows, or perform bulk operations like data import/export.
|
||||
|
||||
- **Authentication**: Requires a user `access_token`, which can be generated from **Profile Settings** after logging into your Chatwoot account.
|
||||
- **Availability**: Supported on both **Cloud** and **Self-hosted** Chatwoot installations.
|
||||
- **Example**: [Google Cloud Functions Demo](https://github.com/chatwoot/google-cloud-functions-demo)
|
||||
|
||||
---
|
||||
|
||||
## Client APIs
|
||||
|
||||
Client APIs are intended for building custom messaging experiences over Chatwoot. If you're not using the native website widget or want to embed chat in your mobile app, these APIs are the way to go.
|
||||
|
||||
- **Authentication**: Uses `inbox_identifier` (from **Settings → Configuration** in API inboxes) and `contact_identifier` (returned when creating a contact).
|
||||
- **Availability**: Supported on both **Cloud** and **Self-hosted** Chatwoot installations.
|
||||
- **Examples**:
|
||||
|
||||
- [Client API Demo](https://github.com/chatwoot/client-api-demo)
|
||||
- [Flutter SDK](https://github.com/chatwoot/chatwoot-flutter-sdk)
|
||||
|
||||
---
|
||||
|
||||
## Platform APIs
|
||||
|
||||
Platform APIs are used to manage Chatwoot installations at the admin level. These APIs allow you to control users, roles, and accounts, or sync data from external authentication systems.
|
||||
|
||||
- **Authentication**: Requires an `access_token` generated by a **Platform App**, which can be created in the **Super Admin Console**.
|
||||
- **Availability**: Available on **Self-hosted** / **Managed Hosting** Chatwoot installations only.
|
||||
|
||||
---
|
||||
|
||||
Use the right API for your use case, and you'll be able to extend, customize, and integrate Chatwoot into your stack with ease.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
@@ -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
|
||||
|
||||
@@ -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,26 +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)
|
||||
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
|
||||
|
||||
@@ -40,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
|
||||
|
||||
@@ -56,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
|
||||
|
||||
@@ -67,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'
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -6,6 +6,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)}
|
||||
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
|
||||
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
|
||||
WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)}
|
||||
|
||||
def text(node)
|
||||
content = node.string_content
|
||||
@@ -50,7 +51,8 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
VIMEO_REGEX => :make_vimeo_embed,
|
||||
MP4_REGEX => :make_video_embed,
|
||||
LOOM_REGEX => :make_loom_embed,
|
||||
ARCADE_REGEX => :make_arcade_embed
|
||||
ARCADE_REGEX => :make_arcade_embed,
|
||||
WISTIA_REGEX => :make_wistia_embed
|
||||
}
|
||||
|
||||
embedding_methods.each do |regex, method|
|
||||
@@ -76,67 +78,30 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
|
||||
def make_youtube_embed(youtube_match)
|
||||
video_id = youtube_match[1]
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/#{video_id}"
|
||||
frameborder="0"
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen></iframe>
|
||||
</div>
|
||||
)
|
||||
EmbedRenderer.youtube(video_id)
|
||||
end
|
||||
|
||||
def make_loom_embed(loom_match)
|
||||
video_id = loom_match[1]
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://www.loom.com/embed/#{video_id}"
|
||||
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
||||
</div>
|
||||
)
|
||||
EmbedRenderer.loom(video_id)
|
||||
end
|
||||
|
||||
def make_vimeo_embed(vimeo_match)
|
||||
video_id = vimeo_match[1]
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/#{video_id}?dnt=true"
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
||||
</div>
|
||||
)
|
||||
EmbedRenderer.vimeo(video_id)
|
||||
end
|
||||
|
||||
def make_video_embed(link_url)
|
||||
%(
|
||||
<video width="640" height="360" controls>
|
||||
<source src="#{link_url}" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)
|
||||
EmbedRenderer.video(link_url)
|
||||
end
|
||||
|
||||
def make_wistia_embed(wistia_match)
|
||||
video_id = wistia_match[2]
|
||||
EmbedRenderer.wistia(video_id)
|
||||
end
|
||||
|
||||
def make_arcade_embed(arcade_match)
|
||||
video_id = arcade_match[1]
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://app.arcade.software/embed/#{video_id}"
|
||||
frameborder="0"
|
||||
webkitallowfullscreen
|
||||
mozallowfullscreen
|
||||
allowfullscreen
|
||||
allow="fullscreen"
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
</div>
|
||||
)
|
||||
EmbedRenderer.arcade(video_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
module EmbedRenderer
|
||||
def self.youtube(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/#{video_id}"
|
||||
frameborder="0"
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen></iframe>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
|
||||
def self.loom(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://www.loom.com/embed/#{video_id}"
|
||||
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
|
||||
def self.vimeo(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/#{video_id}?dnt=true"
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
|
||||
def self.video(link_url)
|
||||
%(
|
||||
<video width="640" height="360" controls>
|
||||
<source src="#{link_url}" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)
|
||||
end
|
||||
|
||||
# Generates an HTML embed for a Wistia video.
|
||||
# @param wistia_match [MatchData] A match object from the WISTIA_REGEX regex, where wistia_match[2] contains the video ID.
|
||||
def self.wistia(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 56.25%; height: 0;">
|
||||
<script src="https://fast.wistia.com/player.js" async></script>
|
||||
<script src="https://fast.wistia.com/embed/#{video_id}.js" async type="module"></script>
|
||||
<style>
|
||||
wistia-player[media-id='#{video_id}']:not(:defined) {
|
||||
background: center / contain no-repeat url('https://fast.wistia.com/embed/medias/#{video_id}/swatch');
|
||||
display: block;
|
||||
filter: blur(5px);
|
||||
padding-top:56.25%;
|
||||
}
|
||||
</style>
|
||||
<wistia-player
|
||||
media-id="#{video_id}"
|
||||
aspect="1.7777777777777777"
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
||||
</wistia-player>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
|
||||
def self.arcade(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://app.arcade.software/embed/#{video_id}"
|
||||
frameborder="0"
|
||||
webkitallowfullscreen
|
||||
mozallowfullscreen
|
||||
allowfullscreen
|
||||
allow="fullscreen"
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -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,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
|
||||
)
|
||||
|
||||
+144
-10
@@ -1,22 +1,156 @@
|
||||
namespace :swagger do
|
||||
desc 'build combined swagger.json file from all the fragmented definitions and paths inside swagger folder'
|
||||
task build: :environment do
|
||||
require 'json_refs'
|
||||
require 'json_refs'
|
||||
require 'fileutils'
|
||||
require 'pathname'
|
||||
require 'yaml'
|
||||
require 'json'
|
||||
|
||||
base_path = Rails.root.join('swagger')
|
||||
Dir.chdir(base_path) do
|
||||
swagger_index = YAML.safe_load(File.open('index.yml'))
|
||||
module SwaggerTaskActions
|
||||
def self.execute_build
|
||||
swagger_dir = Rails.root.join('swagger')
|
||||
# Paths relative to swagger_dir for use within Dir.chdir
|
||||
index_yml_relative_path = 'index.yml'
|
||||
swagger_json_relative_path = 'swagger.json'
|
||||
|
||||
Dir.chdir(swagger_dir) do
|
||||
# Operations within this block are relative to swagger_dir
|
||||
swagger_index_content = File.read(index_yml_relative_path)
|
||||
swagger_index = YAML.safe_load(swagger_index_content)
|
||||
|
||||
final_build = JsonRefs.call(
|
||||
swagger_index,
|
||||
resolve_local_ref: false,
|
||||
resolve_file_ref: true,
|
||||
resolve_file_ref: true, # Uses CWD (swagger_dir) for resolving file refs
|
||||
logging: true
|
||||
)
|
||||
File.write('swagger.json', JSON.pretty_generate(final_build))
|
||||
File.write(swagger_json_relative_path, JSON.pretty_generate(final_build))
|
||||
|
||||
# For user messages, provide the absolute path
|
||||
absolute_swagger_json_path = swagger_dir.join(swagger_json_relative_path)
|
||||
puts 'Swagger build was successful.'
|
||||
puts "Generated #{base_path}/swagger.json"
|
||||
puts "Generated #{absolute_swagger_json_path}"
|
||||
puts 'Go to http://localhost:3000/swagger see the changes.'
|
||||
|
||||
# Trigger dependent task
|
||||
Rake::Task['swagger:build_tag_groups'].invoke
|
||||
end
|
||||
end
|
||||
|
||||
def self.execute_build_tag_groups
|
||||
base_swagger_path = Rails.root.join('swagger')
|
||||
tag_groups_output_dir = base_swagger_path.join('tag_groups')
|
||||
full_spec_path = base_swagger_path.join('swagger.json')
|
||||
index_yml_path = base_swagger_path.join('index.yml')
|
||||
|
||||
full_spec = JSON.parse(File.read(full_spec_path))
|
||||
swagger_index = YAML.safe_load(File.read(index_yml_path))
|
||||
tag_groups = swagger_index['x-tagGroups']
|
||||
|
||||
FileUtils.mkdir_p(tag_groups_output_dir)
|
||||
|
||||
tag_groups.each do |tag_group|
|
||||
_process_tag_group(tag_group, full_spec, tag_groups_output_dir)
|
||||
end
|
||||
|
||||
puts 'Tag-specific swagger files generated successfully.'
|
||||
end
|
||||
|
||||
def self.execute_build_for_docs
|
||||
Rake::Task['swagger:build'].invoke # Ensure all swagger files are built first
|
||||
|
||||
developer_docs_public_path = Rails.root.join('developer-docs/public')
|
||||
tag_groups_in_dev_docs_path = developer_docs_public_path.join('swagger/tag_groups')
|
||||
source_tag_groups_path = Rails.root.join('swagger/tag_groups')
|
||||
|
||||
FileUtils.mkdir_p(tag_groups_in_dev_docs_path)
|
||||
puts 'Creating symlinks for developer-docs...'
|
||||
|
||||
symlink_files = %w[platform_swagger.json application_swagger.json client_swagger.json other_swagger.json]
|
||||
symlink_files.each do |file|
|
||||
_create_symlink(source_tag_groups_path.join(file), tag_groups_in_dev_docs_path.join(file))
|
||||
end
|
||||
|
||||
puts 'Symlinks created successfully.'
|
||||
puts 'You can now run the Mintlify dev server to preview the documentation.'
|
||||
end
|
||||
|
||||
# Private helper methods
|
||||
class << self
|
||||
private
|
||||
|
||||
def _process_tag_group(tag_group, full_spec, output_dir)
|
||||
group_name = tag_group['name']
|
||||
tags_in_current_group = tag_group['tags']
|
||||
|
||||
tag_spec = JSON.parse(JSON.generate(full_spec)) # Deep clone
|
||||
|
||||
tag_spec['paths'] = _filter_paths_for_tag_group(tag_spec['paths'], tags_in_current_group)
|
||||
tag_spec['tags'] = _filter_tags_for_tag_group(tag_spec['tags'], tags_in_current_group)
|
||||
|
||||
output_filename = _determine_output_filename(group_name)
|
||||
File.write(output_dir.join(output_filename), JSON.pretty_generate(tag_spec))
|
||||
end
|
||||
|
||||
def _operation_has_matching_tags?(operation, tags_in_group)
|
||||
return false unless operation.is_a?(Hash)
|
||||
|
||||
operation_tags = operation['tags']
|
||||
return false unless operation_tags.is_a?(Array)
|
||||
|
||||
operation_tags.intersect?(tags_in_group)
|
||||
end
|
||||
|
||||
def _filter_paths_for_tag_group(paths_spec, tags_in_group)
|
||||
(paths_spec || {}).filter_map do |path, path_item|
|
||||
next unless path_item.is_a?(Hash)
|
||||
|
||||
operations_with_group_tags = path_item.any? do |_method, operation|
|
||||
_operation_has_matching_tags?(operation, tags_in_group)
|
||||
end
|
||||
[path, path_item] if operations_with_group_tags
|
||||
end.to_h
|
||||
end
|
||||
|
||||
def _filter_tags_for_tag_group(tags_spec, tags_in_group)
|
||||
if tags_spec.is_a?(Array)
|
||||
tags_spec.select { |tag_definition| tags_in_group.include?(tag_definition['name']) }
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def _determine_output_filename(group_name)
|
||||
return 'other_swagger.json' if group_name.casecmp('others').zero?
|
||||
|
||||
sanitized_group_name = group_name.downcase.tr(' ', '_').gsub(/[^a-z0-9_]+/, '')
|
||||
"#{sanitized_group_name}_swagger.json"
|
||||
end
|
||||
|
||||
def _create_symlink(source_file_path, target_file_path)
|
||||
FileUtils.rm_f(target_file_path) # Remove existing to avoid errors
|
||||
|
||||
if File.exist?(source_file_path)
|
||||
relative_source_path = Pathname.new(source_file_path).relative_path_from(target_file_path.dirname)
|
||||
FileUtils.ln_sf(relative_source_path, target_file_path)
|
||||
else
|
||||
puts "Warning: Source file #{source_file_path} not found. Skipping symlink for #{File.basename(target_file_path)}."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
namespace :swagger do
|
||||
desc 'build combined swagger.json file from all the fragmented definitions and paths inside swagger folder'
|
||||
task build: :environment do
|
||||
SwaggerTaskActions.execute_build
|
||||
end
|
||||
|
||||
desc 'build separate swagger files for each tag group'
|
||||
task build_tag_groups: :environment do
|
||||
SwaggerTaskActions.execute_build_tag_groups
|
||||
end
|
||||
|
||||
desc 'build swagger files and create symlinks in developer-docs'
|
||||
task build_for_docs: :environment do
|
||||
SwaggerTaskActions.execute_build_for_docs
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,6 +79,18 @@ describe Messages::Instagram::MessageBuilder do
|
||||
expect(instagram_inbox.messages.count).to be 1
|
||||
end
|
||||
|
||||
it 'discards duplicate messages from webhook events with the same message_id' do
|
||||
messaging = dm_params[:entry][0]['messaging'][0]
|
||||
described_class.new(messaging, instagram_inbox).perform
|
||||
|
||||
initial_message_count = instagram_inbox.messages.count
|
||||
expect(initial_message_count).to be 1
|
||||
|
||||
described_class.new(messaging, instagram_inbox).perform
|
||||
|
||||
expect(instagram_inbox.messages.count).to eq initial_message_count
|
||||
end
|
||||
|
||||
it 'creates message for shared reel' do
|
||||
messaging = shared_reel_params[:entry][0]['messaging'][0]
|
||||
described_class.new(messaging, instagram_inbox).perform
|
||||
@@ -151,11 +163,15 @@ describe Messages::Instagram::MessageBuilder do
|
||||
end
|
||||
|
||||
it 'does not create message for unsupported file type' do
|
||||
conversation
|
||||
|
||||
# try to create a message with unsupported file type
|
||||
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
|
||||
messaging = story_mention_params[:entry][0][:messaging][0]
|
||||
|
||||
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
|
||||
|
||||
# Conversation should exist but no new message should be created
|
||||
expect(instagram_inbox.conversations.count).to be 1
|
||||
expect(instagram_inbox.messages.count).to be 0
|
||||
end
|
||||
|
||||
@@ -189,19 +189,22 @@ describe Messages::Instagram::Messenger::MessageBuilder do
|
||||
profile_pic: 'https://chatwoot-assets.local/sample.png'
|
||||
}.with_indifferent_access
|
||||
)
|
||||
|
||||
conversation
|
||||
|
||||
# create a message with unsupported file type
|
||||
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
|
||||
messaging = story_mention_params[:entry][0][:messaging][0]
|
||||
contact_inbox
|
||||
|
||||
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: false).perform
|
||||
|
||||
instagram_messenger_inbox.reload
|
||||
|
||||
# we would have contact created but message and attachments won't be created
|
||||
# Conversation should exist but no new message should be created
|
||||
expect(instagram_messenger_inbox.conversations.count).to be 1
|
||||
expect(instagram_messenger_inbox.messages.count).to be 0
|
||||
|
||||
contact = instagram_messenger_channel.inbox.contacts.first
|
||||
|
||||
expect(contact.name).to eq('Jane Dae')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -76,6 +76,54 @@ RSpec.describe 'Platform Users API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /platform/api/v1/users/{user_id}/token' do
|
||||
context 'when it is an unauthenticated platform app' do
|
||||
it 'returns unauthorized' do
|
||||
post "/platform/api/v1/users/#{user.id}/token"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an invalid platform app token' do
|
||||
it 'returns unauthorized' do
|
||||
post "/platform/api/v1/users/#{user.id}/token", headers: { api_access_token: 'invalid' }, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated platform app' do
|
||||
let(:platform_app) { create(:platform_app) }
|
||||
|
||||
it 'returns unauthorized when its not a permissible object' do
|
||||
post "/platform/api/v1/users/#{user.id}/token", headers: { api_access_token: platform_app.access_token.token }, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns access token for the user with expiry and user info' do
|
||||
create(:platform_app_permissible, platform_app: platform_app, permissible: user)
|
||||
|
||||
post "/platform/api/v1/users/#{user.id}/token",
|
||||
headers: { api_access_token: platform_app.access_token.token }, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
data = response.parsed_body
|
||||
|
||||
# Check access token and expiry
|
||||
expect(data['access_token']).to eq(user.access_token.token)
|
||||
expect(data['expiry']).to be_nil
|
||||
|
||||
# Check user info
|
||||
expect(data['user']).to include(
|
||||
'id' => user.id,
|
||||
'name' => user.name,
|
||||
'display_name' => user.display_name,
|
||||
'email' => user.email,
|
||||
'pubsub_token' => user.pubsub_token
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /platform/api/v1/users/' do
|
||||
context 'when it is an unauthenticated platform app' do
|
||||
it 'returns unauthorized' do
|
||||
|
||||
+50
-5
@@ -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
|
||||
|
||||
+62
-1
@@ -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,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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -143,6 +143,17 @@ describe CustomMarkdownRenderer do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a wistia URL' do
|
||||
let(:wistia_url) { 'https://chatwoot.wistia.com/medias/kjwjeq6f9i' }
|
||||
|
||||
it 'renders a custom element with Wistia embed code' do
|
||||
output = render_markdown_link(wistia_url)
|
||||
expect(output).to include('<script src="https://fast.wistia.com/player.js" async></script>')
|
||||
expect(output).to include('<wistia-player')
|
||||
expect(output).to include('media-id="kjwjeq6f9i"')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple links including Arcade are present' do
|
||||
it 'renders Arcade embed along with other content types' do
|
||||
markdown = "\n[arcade](https://app.arcade.software/share/ARCADE_ID)\n\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\n"
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -16,6 +16,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ContactMapper do
|
||||
name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john@example.com',
|
||||
# the phone number is intentionally wrong
|
||||
phone_number: '+1234567890'
|
||||
)
|
||||
|
||||
@@ -29,6 +30,19 @@ RSpec.describe Crm::Leadsquared::Mappers::ContactMapper do
|
||||
'Source' => 'Test Brand'
|
||||
)
|
||||
end
|
||||
|
||||
it 'represents the phone number correctly' do
|
||||
contact.update!(
|
||||
name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john@example.com',
|
||||
phone_number: '+917507684392'
|
||||
)
|
||||
|
||||
mapped_data = described_class.map(contact)
|
||||
|
||||
expect(mapped_data).to include('Mobile' => '+91-7507684392')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -194,23 +194,44 @@ describe MessageTemplates::HookExecutionService do
|
||||
expect(out_of_office_service).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'does not call ::MessageTemplates::Template::OutOfOffice when there are recent outgoing messages' do
|
||||
contact = create(:contact)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
context 'with recent outgoing messages' do
|
||||
it 'does not call ::MessageTemplates::Template::OutOfOffice when there are recent outgoing messages' do
|
||||
contact = create(:contact)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
|
||||
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
|
||||
conversation.inbox.working_hours.today.update!(closed_all_day: true)
|
||||
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
|
||||
conversation.inbox.working_hours.today.update!(closed_all_day: true)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :outgoing, created_at: 2.minutes.ago)
|
||||
create(:message, conversation: conversation, message_type: :outgoing, created_at: 2.minutes.ago)
|
||||
|
||||
out_of_office_service = double
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
out_of_office_service = double
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
create(:message, conversation: conversation)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
expect(out_of_office_service).not_to have_received(:perform)
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
expect(out_of_office_service).not_to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'ignores private note and calls ::MessageTemplates::Template::OutOfOffice' do
|
||||
contact = create(:contact)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
|
||||
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
|
||||
conversation.inbox.working_hours.today.update!(closed_all_day: true)
|
||||
|
||||
create(:message, conversation: conversation, private: true, message_type: :outgoing, created_at: 2.minutes.ago)
|
||||
|
||||
out_of_office_service = double
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: conversation)
|
||||
expect(out_of_office_service).to have_received(:perform)
|
||||
end
|
||||
end
|
||||
|
||||
it 'will not calls ::MessageTemplates::Template::OutOfOffice when outgoing message' do
|
||||
|
||||
@@ -6,4 +6,4 @@ properties:
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/request_error'
|
||||
$ref: '#/components/schemas/request_error'
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
## ---------- ERRORS ------------- ##
|
||||
## ---------- ERRORS -------------- ##
|
||||
## -------------------------------- ##
|
||||
bad_request_error:
|
||||
$ref: ./error/bad_request.yml
|
||||
request_error:
|
||||
$ref: ./error/request.yml
|
||||
|
||||
|
||||
|
||||
## ---------- RESOURCE ------------- ##
|
||||
## ---------- RESOURCE ------------ ##
|
||||
## -------------------------------- ##
|
||||
|
||||
generic_id:
|
||||
@@ -18,12 +16,20 @@ custom_attribute:
|
||||
$ref: ./resource/custom_attribute.yml
|
||||
automation_rule:
|
||||
$ref: ./resource/automation_rule.yml
|
||||
automation_rule_item:
|
||||
$ref: ./resource/automation_rule_item.yml
|
||||
portal:
|
||||
$ref: ./resource/portal.yml
|
||||
category:
|
||||
$ref: ./resource/category.yml
|
||||
article:
|
||||
$ref: ./resource/article.yml
|
||||
portal_single:
|
||||
$ref: ./resource/portal_single.yml
|
||||
portal_config:
|
||||
$ref: ./resource/portal_config.yml
|
||||
portal_logo:
|
||||
$ref: ./resource/portal_logo.yml
|
||||
portal_meta:
|
||||
$ref: ./resource/portal_meta.yml
|
||||
portal_item:
|
||||
$ref: ./resource/portal_item.yml
|
||||
category:
|
||||
$ref: ./resource/category.yml
|
||||
article:
|
||||
@@ -40,6 +46,8 @@ agent:
|
||||
$ref: ./resource/agent.yml
|
||||
inbox:
|
||||
$ref: ./resource/inbox.yml
|
||||
inbox_contact:
|
||||
$ref: ./resource/inbox_contact.yml
|
||||
agent_bot:
|
||||
$ref: ./resource/agent_bot.yml
|
||||
contact_inboxes:
|
||||
@@ -52,6 +60,8 @@ webhook:
|
||||
$ref: ./resource/webhook.yml
|
||||
account:
|
||||
$ref: ./resource/account.yml
|
||||
account_user:
|
||||
$ref: ./resource/account_user.yml
|
||||
platform_account:
|
||||
$ref: ./resource/platform_account.yml
|
||||
team:
|
||||
@@ -71,13 +81,18 @@ public_message:
|
||||
public_inbox:
|
||||
$ref: ./resource/public/inbox.yml
|
||||
|
||||
|
||||
## ---------- REQUEST------------- ##
|
||||
## ---------- REQUEST ------------- ##
|
||||
## -------------------------------- ##
|
||||
|
||||
account_create_update_payload:
|
||||
$ref: ./request/account/create_update_payload.yml
|
||||
|
||||
account_user_create_update_payload:
|
||||
$ref: ./request/account_user/create_update_payload.yml
|
||||
|
||||
platform_agent_bot_create_update_payload:
|
||||
$ref: ./request/platform/agent_bot/create_update_payload.yml
|
||||
|
||||
agent_bot_create_update_payload:
|
||||
$ref: ./request/agent_bot/create_update_payload.yml
|
||||
|
||||
@@ -89,21 +104,36 @@ canned_response_create_update_payload:
|
||||
|
||||
custom_attribute_create_update_payload:
|
||||
$ref: ./request/custom_attribute/create_update_payload.yml
|
||||
## contact
|
||||
contact_create:
|
||||
$ref: ./request/contact/create.yml
|
||||
contact_update:
|
||||
$ref: ./request/contact/update.yml
|
||||
|
||||
## conversation
|
||||
conversation_message_create:
|
||||
$ref: ./request/conversation/create_message.yml
|
||||
## Agent
|
||||
agent_create_payload:
|
||||
$ref: ./request/agent/create_payload.yml
|
||||
agent_update_payload:
|
||||
$ref: ./request/agent/update_payload.yml
|
||||
|
||||
# Team request Payload
|
||||
## Contact
|
||||
contact_create_payload:
|
||||
$ref: ./request/contact/create_payload.yml
|
||||
contact_update_payload:
|
||||
$ref: ./request/contact/update_payload.yml
|
||||
|
||||
## Conversation
|
||||
conversation_create_payload:
|
||||
$ref: ./request/conversation/create_payload.yml
|
||||
conversation_message_create_payload:
|
||||
$ref: ./request/conversation/create_message_payload.yml
|
||||
|
||||
# Inbox
|
||||
inbox_create_payload:
|
||||
$ref: ./request/inbox/create_payload.yml
|
||||
inbox_update_payload:
|
||||
$ref: ./request/inbox/update_payload.yml
|
||||
|
||||
# Team
|
||||
team_create_update_payload:
|
||||
$ref: ./request/team/create_update_payload.yml
|
||||
|
||||
# Custom Filter request Payload
|
||||
# Custom Filter
|
||||
custom_filter_create_update_payload:
|
||||
$ref: ./request/custom_filter/create_update_payload.yml
|
||||
|
||||
@@ -128,7 +158,6 @@ category_create_update_payload:
|
||||
article_create_update_payload:
|
||||
$ref: ./request/portal/article_create_update_payload.yml
|
||||
|
||||
|
||||
## public requests
|
||||
public_contact_create_update_payload:
|
||||
$ref: ./request/public/contact/create_update_payload.yml
|
||||
@@ -141,37 +170,36 @@ public_message_update_payload:
|
||||
public_conversation_create_payload:
|
||||
$ref: ./request/public/conversation/create_payload.yml
|
||||
|
||||
## ---------- RESPONSE ------------- ##
|
||||
## ---------- RESPONSE ------------ ##
|
||||
## -------------------------------- ##
|
||||
|
||||
## contact
|
||||
## Contact
|
||||
extended_contact:
|
||||
allOf:
|
||||
- $ref: '#/definitions/contact'
|
||||
- $ref: '#/components/schemas/contact'
|
||||
- $ref: ./resource/extension/contact/show.yml
|
||||
contact_base:
|
||||
allOf:
|
||||
- $ref: '#/definitions/generic_id'
|
||||
- $ref: '#/definitions/contact'
|
||||
- $ref: '#/components/schemas/generic_id'
|
||||
- $ref: '#/components/schemas/contact'
|
||||
contact_list:
|
||||
type: array
|
||||
description: 'array of contacts'
|
||||
items:
|
||||
allOf:
|
||||
- $ref: '#/definitions/generic_id'
|
||||
- $ref: '#/definitions/contact'
|
||||
- $ref: '#/components/schemas/contact'
|
||||
contact_conversations:
|
||||
type: array
|
||||
description: 'array of conversations'
|
||||
items:
|
||||
allOf:
|
||||
- $ref: '#/definitions/conversation'
|
||||
- $ref: '#/components/schemas/conversation'
|
||||
- $ref: ./resource/extension/contact/conversation.yml
|
||||
- $ref: ./resource/extension/conversation/with_display_id.yml
|
||||
contact_labels:
|
||||
$ref: ./resource/extension/contact/labels.yml
|
||||
|
||||
## conversation
|
||||
## Conversation
|
||||
conversation_list:
|
||||
$ref: ./resource/extension/conversation/list.yml
|
||||
conversation_show:
|
||||
@@ -181,9 +209,33 @@ conversation_status_toggle:
|
||||
conversation_labels:
|
||||
$ref: ./resource/extension/conversation/labels.yml
|
||||
|
||||
|
||||
## report
|
||||
## Report
|
||||
account_summary:
|
||||
$ref: './resource/reports/summary.yml'
|
||||
agent_conversation_metrics:
|
||||
$ref: './resource/reports/conversation/agent.yml'
|
||||
|
||||
contact_detail:
|
||||
$ref: ./resource/contact_detail.yml
|
||||
message_detailed:
|
||||
$ref: ./resource/message_detailed.yml
|
||||
conversation_meta:
|
||||
$ref: ./resource/conversation_meta.yml
|
||||
conversation_messages:
|
||||
$ref: ./resource/conversation_messages.yml
|
||||
contact_meta:
|
||||
$ref: ./resource/contact_meta.yml
|
||||
contact_inbox:
|
||||
$ref: ./resource/contact_inbox.yml
|
||||
contact_list_item:
|
||||
$ref: ./resource/contact_list_item.yml
|
||||
contacts_list_response:
|
||||
$ref: ./resource/contacts_list_response.yml
|
||||
contact_show_response:
|
||||
$ref: ./resource/contact_show_response.yml
|
||||
contact_conversation_message:
|
||||
$ref: ./resource/contact_conversation_message.yml
|
||||
contact_conversations_response:
|
||||
$ref: ./resource/contact_conversations_response.yml
|
||||
contactable_inboxes_response:
|
||||
$ref: ./resource/contactable_inboxes_response.yml
|
||||
|
||||
@@ -3,3 +3,29 @@ properties:
|
||||
name:
|
||||
type: string
|
||||
description: Name of the account
|
||||
example: 'My Account'
|
||||
locale:
|
||||
type: string
|
||||
description: The locale of the account
|
||||
example: 'en'
|
||||
domain:
|
||||
type: string
|
||||
description: The domain of the account
|
||||
example: 'example.com'
|
||||
support_email:
|
||||
type: string
|
||||
description: The support email of the account
|
||||
example: 'support@example.com'
|
||||
status:
|
||||
type: string
|
||||
enum: ['active', 'suspended']
|
||||
description: The status of the account
|
||||
example: 'active'
|
||||
limits:
|
||||
type: object
|
||||
description: The limits of the account
|
||||
example: {}
|
||||
custom_attributes:
|
||||
type: object
|
||||
description: The custom attributes of the account
|
||||
example: {}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
type: object
|
||||
required:
|
||||
- user_id
|
||||
- role
|
||||
properties:
|
||||
user_id:
|
||||
type: integer
|
||||
description: The ID of the user
|
||||
example: 1
|
||||
role:
|
||||
type: string
|
||||
description: whether user is an administrator or agent
|
||||
example: administrator
|
||||
@@ -0,0 +1,28 @@
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- email
|
||||
- role
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Full Name of the agent
|
||||
example: 'John Doe'
|
||||
email:
|
||||
type: string
|
||||
description: Email of the Agent
|
||||
example: 'john.doe@acme.inc'
|
||||
role:
|
||||
type: string
|
||||
enum: ['agent', 'administrator']
|
||||
description: Whether its administrator or agent
|
||||
example: 'agent'
|
||||
availability_status:
|
||||
type: string
|
||||
enum: ['available', 'busy', 'offline']
|
||||
description: The availability setting of the agent.
|
||||
example: 'available'
|
||||
auto_offline:
|
||||
type: boolean
|
||||
description: Whether the availability status of agent is configured to go offline automatically when away.
|
||||
example: true
|
||||
@@ -0,0 +1,18 @@
|
||||
type: object
|
||||
required:
|
||||
- role
|
||||
properties:
|
||||
role:
|
||||
type: string
|
||||
enum: ['agent', 'administrator']
|
||||
description: Whether its administrator or agent
|
||||
example: 'agent'
|
||||
availability_status:
|
||||
type: string
|
||||
enum: ['available', 'busy', 'offline']
|
||||
description: The availability status of the agent.
|
||||
example: 'available'
|
||||
auto_offline:
|
||||
type: boolean
|
||||
description: Whether the availability status of agent is configured to go offline automatically when away.
|
||||
example: true
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user