Merge branch 'develop' into fix/CW-3385
This commit is contained in:
@@ -12,7 +12,12 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
||||
|
||||
def process_event
|
||||
response = @hook.process_event(params[:event])
|
||||
if response[:error]
|
||||
|
||||
# for cases like an invalid event, or when conversation does not have enough messages
|
||||
# for a label suggestion, the response is nil
|
||||
if response.nil?
|
||||
render json: { message: nil }
|
||||
elsif response[:error]
|
||||
render json: { error: response[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
render json: { message: response[:message] }
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ export default {
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
class="label-suggestion--option"
|
||||
class="label-suggestion--option !px-0"
|
||||
@click="pushOrAddLabel(label.title)"
|
||||
>
|
||||
<woot-label
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useIntegrationHook } from '../useIntegrationHook';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
|
||||
describe('useIntegrationHook', () => {
|
||||
let integrationGetter;
|
||||
|
||||
beforeEach(() => {
|
||||
integrationGetter = vi.fn();
|
||||
useMapGetter.mockReturnValue({ value: integrationGetter });
|
||||
});
|
||||
|
||||
it('should return the correct computed properties', async () => {
|
||||
const mockIntegration = {
|
||||
id: 1,
|
||||
hook_type: 'inbox',
|
||||
hooks: ['hook1', 'hook2'],
|
||||
allow_multiple_hooks: true,
|
||||
};
|
||||
integrationGetter.mockReturnValue(mockIntegration);
|
||||
|
||||
const hook = useIntegrationHook(1);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(hook.integration.value).toEqual(mockIntegration);
|
||||
expect(hook.integrationType.value).toBe('multiple');
|
||||
expect(hook.isIntegrationMultiple.value).toBe(true);
|
||||
expect(hook.isIntegrationSingle.value).toBe(false);
|
||||
expect(hook.isHookTypeInbox.value).toBe(true);
|
||||
expect(hook.hasConnectedHooks.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle single integration type correctly', async () => {
|
||||
const mockIntegration = {
|
||||
id: 2,
|
||||
hook_type: 'channel',
|
||||
hooks: [],
|
||||
allow_multiple_hooks: false,
|
||||
};
|
||||
integrationGetter.mockReturnValue(mockIntegration);
|
||||
|
||||
const hook = useIntegrationHook(2);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(hook.integration.value).toEqual(mockIntegration);
|
||||
expect(hook.integrationType.value).toBe('single');
|
||||
expect(hook.isIntegrationMultiple.value).toBe(false);
|
||||
expect(hook.isIntegrationSingle.value).toBe(true);
|
||||
expect(hook.isHookTypeInbox.value).toBe(false);
|
||||
expect(hook.hasConnectedHooks.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
/**
|
||||
* Composable for managing integration hooks
|
||||
* @param {string|number} integrationId - The ID of the integration
|
||||
* @returns {Object} An object containing computed properties for the integration
|
||||
*/
|
||||
export const useIntegrationHook = integrationId => {
|
||||
const integrationGetter = useMapGetter('integrations/getIntegration');
|
||||
|
||||
/**
|
||||
* The integration object
|
||||
* @type {import('vue').ComputedRef<Object>}
|
||||
*/
|
||||
const integration = computed(() => {
|
||||
return integrationGetter.value(integrationId);
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the integration hook type is 'inbox'
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const isHookTypeInbox = computed(() => {
|
||||
return integration.value.hook_type === 'inbox';
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the integration has any connected hooks
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const hasConnectedHooks = computed(() => {
|
||||
return !!integration.value.hooks.length;
|
||||
});
|
||||
|
||||
/**
|
||||
* The type of integration: 'multiple' or 'single'
|
||||
* @type {import('vue').ComputedRef<string>}
|
||||
*/
|
||||
const integrationType = computed(() => {
|
||||
return integration.value.allow_multiple_hooks ? 'multiple' : 'single';
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the integration allows multiple hooks
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const isIntegrationMultiple = computed(() => {
|
||||
return integrationType.value === 'multiple';
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the integration allows only a single hook
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const isIntegrationSingle = computed(() => {
|
||||
return integrationType.value === 'single';
|
||||
});
|
||||
|
||||
return {
|
||||
integration,
|
||||
integrationType,
|
||||
isIntegrationMultiple,
|
||||
isIntegrationSingle,
|
||||
isHookTypeInbox,
|
||||
hasConnectedHooks,
|
||||
};
|
||||
};
|
||||
+20
-19
@@ -2,7 +2,7 @@
|
||||
import { isEmptyObject } from '../../../../helper/commons';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import hookMixin from './hookMixin';
|
||||
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
|
||||
import NewHook from './NewHook.vue';
|
||||
import SingleIntegrationHooks from './SingleIntegrationHooks.vue';
|
||||
import MultipleIntegrationHooks from './MultipleIntegrationHooks.vue';
|
||||
@@ -13,13 +13,28 @@ export default {
|
||||
SingleIntegrationHooks,
|
||||
MultipleIntegrationHooks,
|
||||
},
|
||||
mixins: [hookMixin],
|
||||
props: {
|
||||
integrationId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { integrationId } = props;
|
||||
|
||||
const {
|
||||
integration,
|
||||
isIntegrationMultiple,
|
||||
isIntegrationSingle,
|
||||
isHookTypeInbox,
|
||||
} = useIntegrationHook(integrationId);
|
||||
return {
|
||||
integration,
|
||||
isIntegrationMultiple,
|
||||
isIntegrationSingle,
|
||||
isHookTypeInbox,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: {},
|
||||
@@ -31,23 +46,9 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ uiFlags: 'integrations/getUIFlags' }),
|
||||
integration() {
|
||||
return this.$store.getters['integrations/getIntegration'](
|
||||
this.integrationId
|
||||
);
|
||||
},
|
||||
showIntegrationHooks() {
|
||||
return !this.uiFlags.isFetching && !isEmptyObject(this.integration);
|
||||
},
|
||||
integrationType() {
|
||||
return this.integration.allow_multiple_hooks ? 'multiple' : 'single';
|
||||
},
|
||||
isIntegrationMultiple() {
|
||||
return this.integrationType === 'multiple';
|
||||
},
|
||||
isIntegrationSingle() {
|
||||
return this.integrationType === 'single';
|
||||
},
|
||||
showAddButton() {
|
||||
return this.showIntegrationHooks && this.isIntegrationMultiple;
|
||||
},
|
||||
@@ -120,14 +121,14 @@ export default {
|
||||
<div v-if="showIntegrationHooks" class="w-full">
|
||||
<div v-if="isIntegrationMultiple">
|
||||
<MultipleIntegrationHooks
|
||||
:integration="integration"
|
||||
:integration-id="integrationId"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isIntegrationSingle">
|
||||
<SingleIntegrationHooks
|
||||
:integration="integration"
|
||||
:integration-id="integrationId"
|
||||
@add="openAddHookModal"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
@@ -135,7 +136,7 @@ export default {
|
||||
</div>
|
||||
|
||||
<woot-modal :show.sync="showAddHookModal" :on-close="hideAddHookModal">
|
||||
<NewHook :integration="integration" @close="hideAddHookModal" />
|
||||
<NewHook :integration-id="integrationId" @close="hideAddHookModal" />
|
||||
</woot-modal>
|
||||
|
||||
<woot-delete-modal
|
||||
|
||||
+9
-5
@@ -1,14 +1,18 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import hookMixin from './hookMixin';
|
||||
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
|
||||
export default {
|
||||
mixins: [hookMixin],
|
||||
props: {
|
||||
integration: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
integrationId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { integration, isHookTypeInbox, hasConnectedHooks } =
|
||||
useIntegrationHook(props.integrationId);
|
||||
return { integration, isHookTypeInbox, hasConnectedHooks };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
|
||||
@@ -2,16 +2,22 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import hookMixin from './hookMixin';
|
||||
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
|
||||
|
||||
export default {
|
||||
mixins: [hookMixin],
|
||||
props: {
|
||||
integration: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
integrationId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { integration, isHookTypeInbox } = useIntegrationHook(
|
||||
props.integrationId
|
||||
);
|
||||
|
||||
return { integration, isHookTypeInbox };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
endPoint: '',
|
||||
|
||||
+10
-5
@@ -1,13 +1,18 @@
|
||||
<script>
|
||||
import hookMixin from './hookMixin';
|
||||
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
|
||||
export default {
|
||||
mixins: [hookMixin],
|
||||
props: {
|
||||
integration: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
integrationId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { integration, hasConnectedHooks } = useIntegrationHook(
|
||||
props.integrationId
|
||||
);
|
||||
return { integration, hasConnectedHooks };
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import FileBubble from 'widget/components/FileBubble.vue';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import configMixin from '../mixins/configMixin';
|
||||
import { useMessage } from '../composables/useMessage';
|
||||
import messageMixin from '../mixins/messageMixin';
|
||||
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
|
||||
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
|
||||
import ReplyToChip from 'widget/components/ReplyToChip.vue';
|
||||
@@ -28,7 +28,7 @@ export default {
|
||||
MessageReplyButton,
|
||||
ReplyToChip,
|
||||
},
|
||||
mixins: [configMixin, darkModeMixin],
|
||||
mixins: [configMixin, messageMixin, darkModeMixin],
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
@@ -39,15 +39,6 @@ export default {
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { messageContentAttributes, hasAttachments } = useMessage(
|
||||
props.message
|
||||
);
|
||||
return {
|
||||
messageContentAttributes,
|
||||
hasAttachments,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasImageError: false,
|
||||
|
||||
@@ -6,7 +6,7 @@ import VideoBubble from 'widget/components/VideoBubble.vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import FileBubble from 'widget/components/FileBubble.vue';
|
||||
import { messageStamp } from 'shared/helpers/timeHelper';
|
||||
import { useMessage } from '../composables/useMessage';
|
||||
import messageMixin from '../mixins/messageMixin';
|
||||
import ReplyToChip from 'widget/components/ReplyToChip.vue';
|
||||
import DragWrapper from 'widget/components/DragWrapper.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
@@ -25,6 +25,7 @@ export default {
|
||||
ReplyToChip,
|
||||
DragWrapper,
|
||||
},
|
||||
mixins: [messageMixin],
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
@@ -35,12 +36,6 @@ export default {
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { hasAttachments } = useMessage(props.message);
|
||||
return {
|
||||
hasAttachments,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasImageError: false,
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { reactive, nextTick } from 'vue';
|
||||
import { useMessage } from '../useMessage';
|
||||
|
||||
describe('useMessage', () => {
|
||||
it('should handle deleted messages', () => {
|
||||
const message = reactive({
|
||||
content_attributes: { deleted: true },
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const { messageContentAttributes, hasAttachments } = useMessage(message);
|
||||
|
||||
expect(messageContentAttributes.value).toEqual({ deleted: true });
|
||||
expect(hasAttachments.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle non-deleted messages with attachments', () => {
|
||||
const message = reactive({
|
||||
content_attributes: {},
|
||||
attachments: ['attachment1', 'attachment2'],
|
||||
});
|
||||
|
||||
const { messageContentAttributes, hasAttachments } = useMessage(message);
|
||||
|
||||
expect(messageContentAttributes.value).toEqual({});
|
||||
expect(hasAttachments.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle messages without content_attributes', () => {
|
||||
const message = reactive({
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const { messageContentAttributes, hasAttachments } = useMessage(message);
|
||||
|
||||
expect(messageContentAttributes.value).toEqual({});
|
||||
expect(hasAttachments.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle messages with empty attachments array', () => {
|
||||
const message = reactive({
|
||||
content_attributes: { someAttribute: 'value' },
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const { messageContentAttributes, hasAttachments } = useMessage(message);
|
||||
|
||||
expect(messageContentAttributes.value).toEqual({ someAttribute: 'value' });
|
||||
expect(hasAttachments.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should update reactive properties when message changes', async () => {
|
||||
const message = reactive({
|
||||
content_attributes: {},
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const { messageContentAttributes, hasAttachments } = useMessage(message);
|
||||
|
||||
expect(messageContentAttributes.value).toEqual({});
|
||||
expect(hasAttachments.value).toBe(false);
|
||||
|
||||
message.content_attributes = { updated: true };
|
||||
message.attachments.push('newAttachment');
|
||||
await nextTick();
|
||||
|
||||
expect(messageContentAttributes.value).toEqual({ updated: true });
|
||||
expect(hasAttachments.value).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
/**
|
||||
* Composable for handling message-related computations.
|
||||
* @param {Object} message - The message object to be processed.
|
||||
* @returns {Object} An object containing computed properties for message content and attachments.
|
||||
*/
|
||||
export function useMessage(message) {
|
||||
const messageContentAttributes = computed(() => {
|
||||
const { content_attributes: attribute = {} } = message;
|
||||
return attribute;
|
||||
});
|
||||
|
||||
const hasAttachments = computed(() => {
|
||||
return !!(message.attachments && message.attachments.length > 0);
|
||||
});
|
||||
|
||||
return {
|
||||
messageContentAttributes,
|
||||
hasAttachments,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
computed: {
|
||||
messageContentAttributes() {
|
||||
const { content_attributes: attribute = {} } = this.message;
|
||||
return attribute;
|
||||
},
|
||||
hasAttachments() {
|
||||
return !!(
|
||||
this.message.attachments && this.message.attachments.length > 0
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import messageMixin from '../messageMixin';
|
||||
import messages from './messageFixture';
|
||||
|
||||
describe('messageMixin', () => {
|
||||
let Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [messageMixin],
|
||||
};
|
||||
|
||||
it('deleted messages', async () => {
|
||||
const wrapper = shallowMount(Component, {
|
||||
data() {
|
||||
return {
|
||||
message: messages.deletedMessage,
|
||||
};
|
||||
},
|
||||
});
|
||||
expect(wrapper.vm.messageContentAttributes).toEqual({
|
||||
deleted: true,
|
||||
});
|
||||
expect(wrapper.vm.hasAttachments).toBe(false);
|
||||
});
|
||||
it('non-deleted messages', async () => {
|
||||
const wrapper = shallowMount(Component, {
|
||||
data() {
|
||||
return {
|
||||
message: messages.nonDeletedMessage,
|
||||
};
|
||||
},
|
||||
});
|
||||
expect(wrapper.vm.deletedMessage).toBe(undefined);
|
||||
expect(wrapper.vm.messageContentAttributes).toEqual({});
|
||||
expect(wrapper.vm.hasAttachments).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -42,13 +42,26 @@ class Integrations::OpenaiBaseService
|
||||
return nil unless event_is_cacheable?
|
||||
return nil if cache_key.blank?
|
||||
|
||||
Redis::Alfred.get(cache_key)
|
||||
deserialize_cached_value(Redis::Alfred.get(cache_key))
|
||||
end
|
||||
|
||||
def deserialize_cached_value(value)
|
||||
return nil if value.blank?
|
||||
|
||||
JSON.parse(value, symbolize_names: true)
|
||||
rescue JSON::ParserError
|
||||
# If json parse failed, returning the value as is will fail too
|
||||
# since we access the keys as symbols down the line
|
||||
# So it's best to return nil
|
||||
nil
|
||||
end
|
||||
|
||||
def save_to_cache(response)
|
||||
return nil unless event_is_cacheable?
|
||||
|
||||
Redis::Alfred.setex(cache_key, response)
|
||||
# Serialize to JSON
|
||||
# This makes parsing easy when response is a hash
|
||||
Redis::Alfred.setex(cache_key, response.to_json)
|
||||
end
|
||||
|
||||
def conversation
|
||||
|
||||
@@ -33,7 +33,7 @@ module Redis::RedisKeys
|
||||
LATEST_CHATWOOT_VERSION = 'LATEST_CHATWOOT_VERSION'.freeze
|
||||
# Check if a message create with same source-id is in progress?
|
||||
MESSAGE_SOURCE_KEY = 'MESSAGE_SOURCE_KEY::%<id>s'.freeze
|
||||
OPENAI_CONVERSATION_KEY = 'OPEN_AI_CONVERSATION_KEY::v1::%<event_name>s::%<conversation_id>d::%<updated_at>d'.freeze
|
||||
OPENAI_CONVERSATION_KEY = 'OPEN_AI_CONVERSATION_KEY::V1::%<event_name>s::%<conversation_id>d::%<updated_at>d'.freeze
|
||||
|
||||
## Sempahores / Locks
|
||||
# We don't want to process messages from the same sender concurrently to prevent creating double conversations
|
||||
|
||||
Reference in New Issue
Block a user