diff --git a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb index 13bb2738b..087a9b78d 100644 --- a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb @@ -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] } diff --git a/app/javascript/dashboard/components/widgets/conversation/conversation/LabelSuggestion.vue b/app/javascript/dashboard/components/widgets/conversation/conversation/LabelSuggestion.vue index 4593f25e2..da8488262 100644 --- a/app/javascript/dashboard/components/widgets/conversation/conversation/LabelSuggestion.vue +++ b/app/javascript/dashboard/components/widgets/conversation/conversation/LabelSuggestion.vue @@ -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)" > { + 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); + }); +}); diff --git a/app/javascript/dashboard/composables/useIntegrationHook.js b/app/javascript/dashboard/composables/useIntegrationHook.js new file mode 100644 index 000000000..42546cceb --- /dev/null +++ b/app/javascript/dashboard/composables/useIntegrationHook.js @@ -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} + */ + const integration = computed(() => { + return integrationGetter.value(integrationId); + }); + + /** + * Whether the integration hook type is 'inbox' + * @type {import('vue').ComputedRef} + */ + const isHookTypeInbox = computed(() => { + return integration.value.hook_type === 'inbox'; + }); + + /** + * Whether the integration has any connected hooks + * @type {import('vue').ComputedRef} + */ + const hasConnectedHooks = computed(() => { + return !!integration.value.hooks.length; + }); + + /** + * The type of integration: 'multiple' or 'single' + * @type {import('vue').ComputedRef} + */ + const integrationType = computed(() => { + return integration.value.allow_multiple_hooks ? 'multiple' : 'single'; + }); + + /** + * Whether the integration allows multiple hooks + * @type {import('vue').ComputedRef} + */ + const isIntegrationMultiple = computed(() => { + return integrationType.value === 'multiple'; + }); + + /** + * Whether the integration allows only a single hook + * @type {import('vue').ComputedRef} + */ + const isIntegrationSingle = computed(() => { + return integrationType.value === 'single'; + }); + + return { + integration, + integrationType, + isIntegrationMultiple, + isIntegrationSingle, + isHookTypeInbox, + hasConnectedHooks, + }; +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/IntegrationHooks.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/IntegrationHooks.vue index ad2daa5f3..0f8b9155e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/IntegrationHooks.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/IntegrationHooks.vue @@ -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 {
@@ -135,7 +136,7 @@ export default {
- + 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', diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue index afaf059a6..935268592 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue @@ -2,16 +2,22 @@ diff --git a/app/javascript/widget/components/AgentMessage.vue b/app/javascript/widget/components/AgentMessage.vue index e7d592abd..3296d5128 100755 --- a/app/javascript/widget/components/AgentMessage.vue +++ b/app/javascript/widget/components/AgentMessage.vue @@ -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, diff --git a/app/javascript/widget/components/UserMessage.vue b/app/javascript/widget/components/UserMessage.vue index 7c7d844e9..7f8528e08 100755 --- a/app/javascript/widget/components/UserMessage.vue +++ b/app/javascript/widget/components/UserMessage.vue @@ -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, diff --git a/app/javascript/widget/composables/specs/useMessage.spec.js b/app/javascript/widget/composables/specs/useMessage.spec.js deleted file mode 100644 index 1498c87af..000000000 --- a/app/javascript/widget/composables/specs/useMessage.spec.js +++ /dev/null @@ -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); - }); -}); diff --git a/app/javascript/widget/composables/useMessage.js b/app/javascript/widget/composables/useMessage.js deleted file mode 100644 index 97b7a7800..000000000 --- a/app/javascript/widget/composables/useMessage.js +++ /dev/null @@ -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, - }; -} diff --git a/app/javascript/widget/mixins/messageMixin.js b/app/javascript/widget/mixins/messageMixin.js new file mode 100644 index 000000000..dfa1e0e6a --- /dev/null +++ b/app/javascript/widget/mixins/messageMixin.js @@ -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 + ); + }, + }, +}; diff --git a/app/javascript/widget/mixins/specs/messageMixin.spec.js b/app/javascript/widget/mixins/specs/messageMixin.spec.js new file mode 100644 index 000000000..a6443ccd6 --- /dev/null +++ b/app/javascript/widget/mixins/specs/messageMixin.spec.js @@ -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); + }); +}); diff --git a/lib/integrations/openai_base_service.rb b/lib/integrations/openai_base_service.rb index 056ec998a..1e8b42485 100644 --- a/lib/integrations/openai_base_service.rb +++ b/lib/integrations/openai_base_service.rb @@ -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 diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb index 396d7e50b..f8c467c9a 100644 --- a/lib/redis/redis_keys.rb +++ b/lib/redis/redis_keys.rb @@ -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::%s'.freeze - OPENAI_CONVERSATION_KEY = 'OPEN_AI_CONVERSATION_KEY::v1::%s::%d::%d'.freeze + OPENAI_CONVERSATION_KEY = 'OPEN_AI_CONVERSATION_KEY::V1::%s::%d::%d'.freeze ## Sempahores / Locks # We don't want to process messages from the same sender concurrently to prevent creating double conversations