diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue b/app/javascript/dashboard/components-next/radioCard/RadioCard.story.vue similarity index 97% rename from app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue rename to app/javascript/dashboard/components-next/radioCard/RadioCard.story.vue index df1f8655c..636ded048 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue +++ b/app/javascript/dashboard/components-next/radioCard/RadioCard.story.vue @@ -1,6 +1,6 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue index 249c53abb..abd2552bf 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue @@ -2,7 +2,7 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Avatar from 'next/avatar/Avatar.vue'; -import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue'; +import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue'; const props = defineProps({ senderNameType: { diff --git a/app/javascript/dashboard/routes/dashboard/settings/labels/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/labels/Index.vue index c15f7df28..9b81f30cf 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/labels/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/labels/Index.vue @@ -32,7 +32,10 @@ const records = computed(() => getters['labels/getLabels'].value); const filteredRecords = computed(() => { const query = searchQuery.value.trim(); if (!query) return records.value; - return picoSearch(records.value, query, ['title', 'description']); + return picoSearch(records.value, query, [ + { name: 'title', weight: 4 }, + 'description', + ]); }); const uiFlags = computed(() => getters['labels/getUIFlags'].value); diff --git a/app/javascript/shared/components/ui/label/LabelDropdown.vue b/app/javascript/shared/components/ui/label/LabelDropdown.vue index a1216acd8..71da844e0 100644 --- a/app/javascript/shared/components/ui/label/LabelDropdown.vue +++ b/app/javascript/shared/components/ui/label/LabelDropdown.vue @@ -46,9 +46,7 @@ export default { filteredActiveLabels() { if (!this.search) return this.accountLabels; - return picoSearch(this.accountLabels, this.search, ['title'], { - threshold: 0.9, - }); + return picoSearch(this.accountLabels, this.search, ['title']); }, noResult() { diff --git a/app/services/line/incoming_message_service.rb b/app/services/line/incoming_message_service.rb index 6a1192d02..b30eacd7b 100644 --- a/app/services/line/incoming_message_service.rb +++ b/app/services/line/incoming_message_service.rb @@ -145,7 +145,12 @@ class Line::IncomingMessageService end def set_conversation - @conversation = @contact_inbox.conversations.first + # if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved + @conversation = if @inbox.lock_to_single_conversation + @contact_inbox.conversations.last + else + @contact_inbox.conversations.where.not(status: :resolved).last + end return if @conversation @conversation = ::Conversation.create!(conversation_params) diff --git a/app/services/tiktok/message_service.rb b/app/services/tiktok/message_service.rb index fcd613ec2..7acbb9486 100644 --- a/app/services/tiktok/message_service.rb +++ b/app/services/tiktok/message_service.rb @@ -23,7 +23,12 @@ class Tiktok::MessageService end def conversation - @conversation ||= contact_inbox.conversations.first || create_conversation(channel, contact_inbox, tt_conversation_id) + @conversation ||= if channel.inbox.lock_to_single_conversation + contact_inbox.conversations.order(created_at: :desc).first + else + contact_inbox.conversations.where.not(status: :resolved).order(created_at: :desc).first + end + @conversation ||= create_conversation(channel, contact_inbox, tt_conversation_id) end def create_message diff --git a/app/services/tiktok/messaging_helpers.rb b/app/services/tiktok/messaging_helpers.rb index 71c417cd7..0f9d9e0b3 100644 --- a/app/services/tiktok/messaging_helpers.rb +++ b/app/services/tiktok/messaging_helpers.rb @@ -27,7 +27,15 @@ module Tiktok::MessagingHelpers end def find_conversation(channel, tt_conversation_id) - channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id)&.conversations&.first + contact_inbox = channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id) + return if contact_inbox.blank? + + if channel.inbox.lock_to_single_conversation + contact_inbox.conversations.order(created_at: :desc).first + else + contact_inbox.conversations.where.not(status: :resolved).order(created_at: :desc).first || + contact_inbox.conversations.order(created_at: :desc).first + end end def create_conversation(channel, contact_inbox, tt_conversation_id) diff --git a/enterprise/app/models/concerns/agentable.rb b/enterprise/app/models/concerns/agentable.rb index e5b0b8eef..ed8e0a89f 100644 --- a/enterprise/app/models/concerns/agentable.rb +++ b/enterprise/app/models/concerns/agentable.rb @@ -19,9 +19,11 @@ module Concerns::Agentable state = context.context[:state] || {} conversation_data = state[:conversation] || {} contact_data = state[:contact] || {} + campaign_data = state[:campaign] || {} enhanced_context = enhanced_context.merge( conversation: conversation_data, - contact: contact_data + contact: contact_data, + campaign: campaign_data ) end diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb index 72c44024f..8238ca4d8 100644 --- a/enterprise/app/services/captain/assistant/agent_runner_service.rb +++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb @@ -16,6 +16,8 @@ class Captain::Assistant::AgentRunnerService custom_attributes additional_attributes ].freeze + CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze + def initialize(assistant:, conversation: nil, callbacks: {}) @assistant = assistant @conversation = conversation @@ -129,6 +131,7 @@ class Captain::Assistant::AgentRunnerService state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES) state[:channel_type] = @conversation.inbox&.channel_type state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact + state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign end state diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid index 0dc7d8577..7b20a089e 100644 --- a/enterprise/lib/captain/prompts/assistant.liquid +++ b/enterprise/lib/captain/prompts/assistant.liquid @@ -8,7 +8,7 @@ You are {{name}}, a helpful and knowledgeable assistant. Your role is to primari Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the `captain--tools--faq_lookup` tool for this. -{% if conversation || contact -%} +{% if conversation || contact || campaign.id -%} # Current Context Here's the metadata we have about the current conversation and the contact associated with it: @@ -20,6 +20,10 @@ Here's the metadata we have about the current conversation and the contact assoc {% if contact -%} {% render 'contact' %} {% endif -%} + +{% if campaign.id -%} +{% render 'campaign' %} +{% endif -%} {% endif -%} {% if response_guidelines.size > 0 -%} diff --git a/enterprise/lib/captain/prompts/scenario.liquid b/enterprise/lib/captain/prompts/scenario.liquid index 1148a7c3a..879a39f52 100644 --- a/enterprise/lib/captain/prompts/scenario.liquid +++ b/enterprise/lib/captain/prompts/scenario.liquid @@ -8,7 +8,7 @@ You are a specialized agent called "{{ title }}", your task is to handle the fol If you believe the user's request is not within the scope of your role, you can assign this conversation back to the orchestrator agent using the `handoff_to_{{ assistant_name }}` tool -{% if conversation || contact %} +{% if conversation || contact || campaign.id %} # Current Context Here's the metadata we have about the current conversation and the contact associated with it: @@ -20,6 +20,10 @@ Here's the metadata we have about the current conversation and the contact assoc {% if contact -%} {% render 'contact' %} {% endif -%} + +{% if campaign.id -%} +{% render 'campaign' %} +{% endif -%} {% endif -%} diff --git a/enterprise/lib/captain/prompts/snippets/campaign.liquid b/enterprise/lib/captain/prompts/snippets/campaign.liquid new file mode 100644 index 000000000..db2ac0e8e --- /dev/null +++ b/enterprise/lib/captain/prompts/snippets/campaign.liquid @@ -0,0 +1,8 @@ +# Campaign Context +This conversation was initiated in response to a campaign message. +- Campaign: {{ campaign.title }} +- Type: {{ campaign.campaign_type }} +{% if campaign.description -%} +- Description: {{ campaign.description }} +{% endif -%} +- Original Message Sent: {{ campaign.message }} diff --git a/enterprise/lib/enterprise/chatwoot_hub.rb b/enterprise/lib/enterprise/chatwoot_hub.rb new file mode 100644 index 000000000..a9d572d2d --- /dev/null +++ b/enterprise/lib/enterprise/chatwoot_hub.rb @@ -0,0 +1,9 @@ +module Enterprise::ChatwootHub + ENTERPRISE_BASE_URL = 'https://hub.2.chatwoot.com'.freeze + + def base_url + return ENV.fetch('CHATWOOT_HUB_URL', ENTERPRISE_BASE_URL) if Rails.env.development? + + ENTERPRISE_BASE_URL + end +end diff --git a/lib/chatwoot_hub.rb b/lib/chatwoot_hub.rb index c18fb299b..be5a07e05 100644 --- a/lib/chatwoot_hub.rb +++ b/lib/chatwoot_hub.rb @@ -1,12 +1,30 @@ # TODO: lets use HTTParty instead of RestClient class ChatwootHub - BASE_URL = ENV.fetch('CHATWOOT_HUB_URL', 'https://hub.2.chatwoot.com') - PING_URL = "#{BASE_URL}/ping".freeze - REGISTRATION_URL = "#{BASE_URL}/instances".freeze - PUSH_NOTIFICATION_URL = "#{BASE_URL}/send_push".freeze - EVENTS_URL = "#{BASE_URL}/events".freeze - BILLING_URL = "#{BASE_URL}/billing".freeze - CAPTAIN_ACCOUNTS_URL = "#{BASE_URL}/instance_captain_accounts".freeze + DEFAULT_BASE_URL = 'https://hub.2.chatwoot.com'.freeze + + def self.base_url + DEFAULT_BASE_URL + end + + def self.ping_url + "#{base_url}/ping" + end + + def self.registration_url + "#{base_url}/instances" + end + + def self.push_notification_url + "#{base_url}/send_push" + end + + def self.events_url + "#{base_url}/events" + end + + def self.billing_base_url + "#{base_url}/billing" + end def self.installation_identifier identifier = InstallationConfig.find_by(name: 'INSTALLATION_IDENTIFIER')&.value @@ -15,7 +33,7 @@ class ChatwootHub end def self.billing_url - "#{BILLING_URL}?installation_identifier=#{installation_identifier}" + "#{billing_base_url}?installation_identifier=#{installation_identifier}" end def self.pricing_plan @@ -68,7 +86,7 @@ class ChatwootHub begin info = instance_config info = info.merge(instance_metrics) unless ENV['DISABLE_TELEMETRY'] - response = RestClient.post(PING_URL, info.to_json, { content_type: :json, accept: :json }) + response = RestClient.post(ping_url, info.to_json, { content_type: :json, accept: :json }) parsed_response = JSON.parse(response) rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e Rails.logger.error "Exception: #{e.message}" @@ -80,7 +98,7 @@ class ChatwootHub def self.register_instance(company_name, owner_name, owner_email) info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true } - RestClient.post(REGISTRATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) + RestClient.post(registration_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e Rails.logger.error "Exception: #{e.message}" rescue StandardError => e @@ -89,32 +107,23 @@ class ChatwootHub def self.send_push(fcm_options) info = { fcm_options: fcm_options } - RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) + RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e Rails.logger.error "Exception: #{e.message}" rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception end - def self.get_captain_settings(account) - info = { - installation_identifier: installation_identifier, - chatwoot_account_id: account.id, - account_name: account.name - } - HTTParty.post(CAPTAIN_ACCOUNTS_URL, - body: info.to_json, - headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' }) - end - def self.emit_event(event_name, event_data) return if ENV['DISABLE_TELEMETRY'] info = { event_name: event_name, event_data: event_data } - RestClient.post(EVENTS_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) + RestClient.post(events_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e Rails.logger.error "Exception: #{e.message}" rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception end end + +ChatwootHub.singleton_class.prepend_mod_with('ChatwootHub') diff --git a/package.json b/package.json index 37b1307cd..b1aa60f00 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", "@chatwoot/prosemirror-schema": "1.3.6", - "@chatwoot/utils": "^0.0.51", + "@chatwoot/utils": "^0.0.52", "@formkit/core": "^1.6.7", "@formkit/vue": "^1.6.7", "@hcaptcha/vue3-hcaptcha": "^1.3.0", @@ -46,7 +46,7 @@ "@radix-ui/colors": "^3.0.0", "@rails/actioncable": "6.1.3", "@rails/ujs": "^7.1.400", - "@scmmishra/pico-search": "0.5.4", + "@scmmishra/pico-search": "0.6.0", "@sentry/vue": "^8.55.0", "@sindresorhus/slugify": "2.2.1", "@tailwindcss/typography": "^0.5.15", @@ -95,6 +95,7 @@ "video.js": "7.18.1", "videojs-record": "4.5.0", "videojs-wavesurfer": "3.8.0", + "virtua": "^0.48.6", "vue": "^3.5.12", "vue-chartjs": "5.3.1", "vue-datepicker-next": "^1.0.3", @@ -103,7 +104,6 @@ "vue-letter": "^0.2.1", "vue-router": "~4.4.5", "vue-upload-component": "^3.1.17", - "vue-virtual-scroller": "^2.0.0-beta.8", "vue3-click-away": "^1.2.4", "vuedraggable": "^4.1.0", "vuex": "~4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e880b4aaf..539a99b21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,8 +26,8 @@ importers: specifier: 1.3.6 version: 1.3.6 '@chatwoot/utils': - specifier: ^0.0.51 - version: 0.0.51 + specifier: ^0.0.52 + version: 0.0.52 '@formkit/core': specifier: ^1.6.7 version: 1.6.7 @@ -59,8 +59,8 @@ importers: specifier: ^7.1.400 version: 7.1.400 '@scmmishra/pico-search': - specifier: 0.5.4 - version: 0.5.4 + specifier: 0.6.0 + version: 0.6.0 '@sentry/vue': specifier: ^8.55.0 version: 8.55.0(pinia@3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)))(vue@3.5.12(typescript@5.6.2)) @@ -205,6 +205,9 @@ importers: videojs-wavesurfer: specifier: 3.8.0 version: 3.8.0 + virtua: + specifier: ^0.48.6 + version: 0.48.6(vue@3.5.12(typescript@5.6.2)) vue: specifier: ^3.5.12 version: 3.5.12(typescript@5.6.2) @@ -229,9 +232,6 @@ importers: vue-upload-component: specifier: ^3.1.17 version: 3.1.17 - vue-virtual-scroller: - specifier: ^2.0.0-beta.8 - version: 2.0.0-beta.8(vue@3.5.12(typescript@5.6.2)) vue3-click-away: specifier: ^1.2.4 version: 1.2.4 @@ -457,8 +457,8 @@ packages: '@chatwoot/prosemirror-schema@1.3.6': resolution: {integrity: sha512-sHRtWqbtiow9mVF1ixim0eGUXfhGK5tuLOdF9Vf53aepjJ+ngEiNVkxQT6FohlEOd886ZsdQxMvmI92IDaUXAQ==} - '@chatwoot/utils@0.0.51': - resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==} + '@chatwoot/utils@0.0.52': + resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==} engines: {node: '>=10'} '@codemirror/commands@6.7.0': @@ -1240,8 +1240,8 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@scmmishra/pico-search@0.5.4': - resolution: {integrity: sha512-JdV8KumQ+pE5tqgQ71xUT9biE/qV//tx3NCqTLkW9Z4tsjKGN0B6kVowmtaZBAtErqir9XiMxsKXRTMF/MpUww==} + '@scmmishra/pico-search@0.6.0': + resolution: {integrity: sha512-1zC2cAwPWuv38VEh0It90fdUWkvX75OwBUjgTj+d5LTltARnf3ydbpcN2Ucl0aATBMmaNqPMcVvT25IOCAqCEA==} '@sentry-internal/browser-utils@8.55.0': resolution: {integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==} @@ -3305,9 +3305,6 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - mitt@2.1.0: - resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==} - mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} @@ -4511,6 +4508,26 @@ packages: videojs-wavesurfer@3.8.0: resolution: {integrity: sha512-qHucCBiEW+4dZ0Zp1k4R1elprUOV+QDw87UDA9QRXtO7GK/MrSdoe/TMFxP9SLnJCiX9xnYdf4OQgrmvJ9UVVw==} + virtua@0.48.6: + resolution: {integrity: sha512-Cl4uMvMV5c9RuOy9zhkFMYwx/V4YLBMYLRSWkO8J46opQZ3P7KMq0CqCVOOAKUckjl/r//D2jWTBGYWzmgtzrQ==} + peerDependencies: + react: '>=16.14.0' + react-dom: '>=16.14.0' + solid-js: '>=1.0' + svelte: '>=5.0' + vue: '>=3.2' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + vite-node@2.0.1: resolution: {integrity: sha512-nVd6kyhPAql0s+xIVJzuF+RSRH8ZimNrm6U8ZvTA4MXv8CHI17TFaQwRaFiK75YX6XeFqZD4IoAaAfi9OR1XvQ==} engines: {node: ^18.0.0 || >=20.0.0} @@ -4625,11 +4642,6 @@ packages: vue-letter@0.2.1: resolution: {integrity: sha512-IYWp47XUikjKfEniWYlFxeJFKABZwAE5IEjz866qCBytBr2dzqVDdjoMDpBP//krxkzN/QZYyHe6C09y/IODYg==} - vue-observe-visibility@2.0.0-alpha.1: - resolution: {integrity: sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==} - peerDependencies: - vue: ^3.0.0 - vue-resize@2.0.0-alpha.1: resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==} peerDependencies: @@ -4643,11 +4655,6 @@ packages: vue-upload-component@3.1.17: resolution: {integrity: sha512-1orTC5apoFzBz4ku2HAydpviaAOck+ABc83rGypIK/Bgl+TqhtoWsQOhXqbb7vDv7pKlvRVWwml9PM224HyhkA==} - vue-virtual-scroller@2.0.0-beta.8: - resolution: {integrity: sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==} - peerDependencies: - vue: ^3.2.0 - vue3-click-away@1.2.4: resolution: {integrity: sha512-O9Z2KlvIhJT8OxaFy04eiZE9rc1Mk/bp+70dLok68ko3Kr8AW5dU+j8avSk4GDQu94FllSr4m5ul4BpzlKOw1A==} @@ -5010,7 +5017,7 @@ snapshots: prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-view: 1.34.1 - '@chatwoot/utils@0.0.51': + '@chatwoot/utils@0.0.52': dependencies: date-fns: 2.30.0 @@ -5788,7 +5795,7 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@scmmishra/pico-search@0.5.4': {} + '@scmmishra/pico-search@0.6.0': {} '@sentry-internal/browser-utils@8.55.0': dependencies: @@ -8226,8 +8233,6 @@ snapshots: minipass@7.1.2: {} - mitt@2.1.0: {} - mitt@3.0.1: {} mlly@1.8.0: @@ -9574,6 +9579,10 @@ snapshots: video.js: 7.18.1 wavesurfer.js: 7.8.6 + virtua@0.48.6(vue@3.5.12(typescript@5.6.2)): + optionalDependencies: + vue: 3.5.12(typescript@5.6.2) + vite-node@2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0): dependencies: cac: 6.7.14 @@ -9692,10 +9701,6 @@ snapshots: dependencies: lettersanitizer: 1.0.6 - vue-observe-visibility@2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)): - dependencies: - vue: 3.5.12(typescript@5.6.2) - vue-resize@2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)): dependencies: vue: 3.5.12(typescript@5.6.2) @@ -9707,13 +9712,6 @@ snapshots: vue-upload-component@3.1.17: {} - vue-virtual-scroller@2.0.0-beta.8(vue@3.5.12(typescript@5.6.2)): - dependencies: - mitt: 2.1.0 - vue: 3.5.12(typescript@5.6.2) - vue-observe-visibility: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)) - vue-resize: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)) - vue3-click-away@1.2.4: {} vue@3.5.12(typescript@5.6.2): diff --git a/spec/controllers/api/v1/accounts/tiktok/authorizations_controller_spec.rb b/spec/controllers/api/v1/accounts/tiktok/authorizations_controller_spec.rb index 54724aba0..dab4dfad3 100644 --- a/spec/controllers/api/v1/accounts/tiktok/authorizations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/tiktok/authorizations_controller_spec.rb @@ -32,23 +32,25 @@ RSpec.describe 'TikTok Authorization API', type: :request do end it 'creates a new authorization and returns the redirect url' do - with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do - post "/api/v1/accounts/#{account.id}/tiktok/authorization", - headers: administrator.create_new_auth_token, - as: :json + travel_to Time.zone.parse('2025-01-01 00:00:00 UTC') do + with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do + post "/api/v1/accounts/#{account.id}/tiktok/authorization", + headers: administrator.create_new_auth_token, + as: :json + end + + expect(response).to have_http_status(:success) + expect(response.parsed_body['success']).to be true + + helper = Class.new do + include Tiktok::IntegrationHelper + end.new + + expected_state = helper.generate_tiktok_token(account.id) + expected_url = Tiktok::AuthClient.authorize_url(state: expected_state) + + expect(response.parsed_body['url']).to eq(expected_url) end - - expect(response).to have_http_status(:success) - expect(response.parsed_body['success']).to be true - - helper = Class.new do - include Tiktok::IntegrationHelper - end.new - - expected_state = helper.generate_tiktok_token(account.id) - expected_url = Tiktok::AuthClient.authorize_url(state: expected_state) - - expect(response.parsed_body['url']).to eq(expected_url) end end end diff --git a/spec/enterprise/lib/chatwoot_hub_spec.rb b/spec/enterprise/lib/chatwoot_hub_spec.rb new file mode 100644 index 000000000..24d78d028 --- /dev/null +++ b/spec/enterprise/lib/chatwoot_hub_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe ChatwootHub do + describe '.base_url' do + it 'uses the static hub url outside development for enterprise edition' do + with_modified_env CHATWOOT_HUB_URL: 'https://custom.example.com' do + allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production')) + + expect(described_class.base_url).to eq('https://hub.2.chatwoot.com') + end + end + + it 'uses CHATWOOT_HUB_URL in development for enterprise edition' do + with_modified_env CHATWOOT_HUB_URL: 'https://custom.example.com' do + allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development')) + + expect(described_class.base_url).to eq('https://custom.example.com') + end + end + end +end diff --git a/spec/enterprise/models/concerns/agentable_spec.rb b/spec/enterprise/models/concerns/agentable_spec.rb index 6b170e8d7..fbf6a58dc 100644 --- a/spec/enterprise/models/concerns/agentable_spec.rb +++ b/spec/enterprise/models/concerns/agentable_spec.rb @@ -97,7 +97,8 @@ RSpec.describe Concerns::Agentable do expected_context = { base_key: 'base_value', conversation: { id: 123 }, - contact: { name: 'John' } + contact: { name: 'John' }, + campaign: {} } expect(Captain::PromptRenderer).to receive(:render).with( @@ -108,6 +109,26 @@ RSpec.describe Concerns::Agentable do dummy_instance.agent_instructions(context_double) end + it 'merges campaign data from context state' do + context_double = instance_double(Agents::RunContext, + context: { + state: { + conversation: { id: 123 }, + contact: { name: 'John' }, + campaign: { id: 10, title: 'Summer Sale', message: 'Check it out' } + } + }) + + expect(Captain::PromptRenderer).to receive(:render).with( + 'dummy_class', + hash_including( + campaign: { id: 10, title: 'Summer Sale', message: 'Check it out' } + ) + ) + + dummy_instance.agent_instructions(context_double) + end + it 'handles context without state' do context_double = instance_double(Agents::RunContext, context: {}) @@ -116,7 +137,8 @@ RSpec.describe Concerns::Agentable do hash_including( base_key: 'base_value', conversation: {}, - contact: {} + contact: {}, + campaign: {} ) ) diff --git a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb index 13e4804ce..0d22b8266 100644 --- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb +++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb @@ -394,6 +394,34 @@ RSpec.describe Captain::Assistant::AgentRunnerService do ) end + it 'does not include campaign when conversation has no campaign' do + state = service.send(:build_state) + + expect(state).not_to have_key(:campaign) + end + + context 'when conversation has a campaign' do + let(:campaign) { create(:campaign, account: account, title: 'Summer Sale', message: 'Check out our deals!', description: 'Seasonal promo') } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, campaign: campaign) } + + it 'includes campaign attributes in state' do + state = service.send(:build_state) + + expect(state[:campaign]).to include( + id: campaign.id, + title: 'Summer Sale', + message: 'Check out our deals!', + description: 'Seasonal promo' + ) + end + + it 'only includes attributes defined in CAMPAIGN_STATE_ATTRIBUTES' do + state = service.send(:build_state) + + expect(state[:campaign].keys).to match_array(described_class::CAMPAIGN_STATE_ATTRIBUTES) + end + end + context 'when conversation is nil' do subject(:service) { described_class.new(assistant: assistant, conversation: nil) } @@ -407,6 +435,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do ) expect(state).not_to have_key(:conversation) expect(state).not_to have_key(:contact) + expect(state).not_to have_key(:campaign) end end end @@ -477,5 +506,11 @@ RSpec.describe Captain::Assistant::AgentRunnerService do :id, :name, :email, :phone_number, :identifier, :contact_type ) end + + it 'defines campaign state attributes' do + expect(described_class::CAMPAIGN_STATE_ATTRIBUTES).to include( + :id, :title, :message, :campaign_type, :description + ) + end end end diff --git a/spec/lib/chatwoot_hub_spec.rb b/spec/lib/chatwoot_hub_spec.rb index 0f53971af..a1051e619 100644 --- a/spec/lib/chatwoot_hub_spec.rb +++ b/spec/lib/chatwoot_hub_spec.rb @@ -1,6 +1,13 @@ require 'rails_helper' describe ChatwootHub do + describe '.base_url' do + it 'uses the static hub url' do + expect(described_class::DEFAULT_BASE_URL).to eq('https://hub.2.chatwoot.com') + expect(described_class.base_url).to eq('https://hub.2.chatwoot.com') + end + end + it 'generates installation identifier' do installation_identifier = described_class.installation_identifier expect(installation_identifier).not_to be_nil @@ -12,7 +19,7 @@ describe ChatwootHub do version = '1.1.1' allow(RestClient).to receive(:post).and_return({ version: version }.to_json) expect(described_class.sync_with_hub['version']).to eq version - expect(RestClient).to have_received(:post).with(described_class::PING_URL, described_class.instance_config + expect(RestClient).to have_received(:post).with(described_class.ping_url, described_class.instance_config .merge(described_class.instance_metrics).to_json, { content_type: :json, accept: :json }) end @@ -21,7 +28,7 @@ describe ChatwootHub do with_modified_env DISABLE_TELEMETRY: 'true' do allow(RestClient).to receive(:post).and_return({ version: version }.to_json) expect(described_class.sync_with_hub['version']).to eq version - expect(RestClient).to have_received(:post).with(described_class::PING_URL, + expect(RestClient).to have_received(:post).with(described_class.ping_url, described_class.instance_config.to_json, { content_type: :json, accept: :json }) end end @@ -41,7 +48,7 @@ describe ChatwootHub do info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true } allow(RestClient).to receive(:post) described_class.register_instance(company_name, owner_name, owner_email) - expect(RestClient).to have_received(:post).with(described_class::REGISTRATION_URL, + expect(RestClient).to have_received(:post).with(described_class.registration_url, info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json }) end end @@ -54,7 +61,7 @@ describe ChatwootHub do info = { event_name: event_name, event_data: event_data } allow(RestClient).to receive(:post) described_class.emit_event(event_name, event_data) - expect(RestClient).to have_received(:post).with(described_class::EVENTS_URL, + expect(RestClient).to have_received(:post).with(described_class.events_url, info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json }) end @@ -64,29 +71,9 @@ describe ChatwootHub do allow(RestClient).to receive(:post) described_class.emit_event(event_name, event_data) expect(RestClient).not_to have_received(:post) - .with(described_class::EVENTS_URL, + .with(described_class.events_url, info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json }) end end end - - context 'when fetching captain settings' do - it 'returns the captain settings' do - account = create(:account) - stub_request(:post, ChatwootHub::CAPTAIN_ACCOUNTS_URL).with( - body: { installation_identifier: described_class.installation_identifier, chatwoot_account_id: account.id, account_name: account.name } - ).to_return( - body: { account_email: 'test@test.com', account_id: '123', access_token: '123', assistant_id: '123' }.to_json - ) - - expect(described_class.get_captain_settings(account).body).to eq( - { - account_email: 'test@test.com', - account_id: '123', - access_token: '123', - assistant_id: '123' - }.to_json - ) - end - end end diff --git a/spec/services/line/incoming_message_service_spec.rb b/spec/services/line/incoming_message_service_spec.rb index a7805ce9b..997777a43 100644 --- a/spec/services/line/incoming_message_service_spec.rb +++ b/spec/services/line/incoming_message_service_spec.rb @@ -405,5 +405,111 @@ describe Line::IncomingMessageService do expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('contacts.csv') end end + + context 'when lock_to_single_conversation is false' do + before do + line_channel.inbox.update(lock_to_single_conversation: false) + end + + it 'creates a new conversation when all previous conversations are resolved' do + line_bot = double + line_user_profile = double + allow(Line::Bot::Client).to receive(:new).and_return(line_bot) + allow(line_bot).to receive(:get_profile).and_return(line_user_profile) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + + # Create a contact and a resolved conversation + described_class.new(inbox: line_channel.inbox, params: params).perform + + # Mark the conversation as resolved + conversation = line_channel.inbox.conversations.last + conversation.update(status: :resolved) + + # Send a new message + new_params = params.deep_dup + new_params[:events][0][:message][:id] = '325709' + new_params[:events][0][:message][:text] = 'Second message' + + described_class.new(inbox: line_channel.inbox, params: new_params).perform + + # Should create a new conversation + expect(line_channel.inbox.conversations.count).to eq(2) + expect(line_channel.inbox.conversations.last.messages.first.content).to eq('Second message') + end + + it 'uses the existing conversation when there is an unresolved conversation' do + line_bot = double + line_user_profile = double + allow(Line::Bot::Client).to receive(:new).and_return(line_bot) + allow(line_bot).to receive(:get_profile).and_return(line_user_profile) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + + # Create a contact and an unresolved conversation + described_class.new(inbox: line_channel.inbox, params: params).perform + + # Send a new message + new_params = params.deep_dup + new_params[:events][0][:message][:id] = '325709' + new_params[:events][0][:message][:text] = 'Second message' + + described_class.new(inbox: line_channel.inbox, params: new_params).perform + + # Should use the same conversation + expect(line_channel.inbox.conversations.count).to eq(1) + expect(line_channel.inbox.conversations.last.messages.count).to eq(2) + expect(line_channel.inbox.conversations.last.messages.last.content).to eq('Second message') + end + end + + context 'when lock_to_single_conversation is true' do + before do + line_channel.inbox.update(lock_to_single_conversation: true) + end + + it 'uses the existing conversation even when it is resolved' do + line_bot = double + line_user_profile = double + allow(Line::Bot::Client).to receive(:new).and_return(line_bot) + allow(line_bot).to receive(:get_profile).and_return(line_user_profile) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + + # Create a contact and a resolved conversation + described_class.new(inbox: line_channel.inbox, params: params).perform + + # Mark the conversation as resolved + conversation = line_channel.inbox.conversations.last + conversation.update(status: :resolved) + + # Send a new message + new_params = params.deep_dup + new_params[:events][0][:message][:id] = '325709' + new_params[:events][0][:message][:text] = 'Second message' + + described_class.new(inbox: line_channel.inbox, params: new_params).perform + + # Should use the same conversation + expect(line_channel.inbox.conversations.count).to eq(1) + expect(line_channel.inbox.conversations.last.messages.count).to eq(2) + expect(line_channel.inbox.conversations.last.messages.last.content).to eq('Second message') + end + end end end diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb index 528161afe..b81b18756 100644 --- a/spec/services/telegram/incoming_message_service_spec.rb +++ b/spec/services/telegram/incoming_message_service_spec.rb @@ -410,6 +410,94 @@ describe Telegram::IncomingMessageService do expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('contact') end end + + context 'when lock_to_single_conversation is false' do + before do + telegram_channel.inbox.update(lock_to_single_conversation: false) + end + + it 'creates a new conversation when all previous conversations are resolved' do + # Create a contact and a resolved conversation + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { 'text' => 'first message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: params).perform + + # Mark the conversation as resolved + conversation = telegram_channel.inbox.conversations.last + conversation.update(status: :resolved) + + # Send a new message + new_params = { + 'update_id' => 2_342_342_343_243, + 'message' => { 'text' => 'second message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: new_params).perform + + # Should create a new conversation + expect(telegram_channel.inbox.conversations.count).to eq(2) + expect(telegram_channel.inbox.conversations.last.messages.first.content).to eq('second message') + end + + it 'uses the existing conversation when there is an unresolved conversation' do + # Create a contact and an unresolved conversation + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { 'text' => 'first message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: params).perform + + # Send a new message + new_params = { + 'update_id' => 2_342_342_343_243, + 'message' => { 'text' => 'second message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: new_params).perform + + # Should use the same conversation + expect(telegram_channel.inbox.conversations.count).to eq(1) + expect(telegram_channel.inbox.conversations.last.messages.count).to eq(2) + expect(telegram_channel.inbox.conversations.last.messages.last.content).to eq('second message') + end + end + + context 'when lock_to_single_conversation is true' do + before do + telegram_channel.inbox.update(lock_to_single_conversation: true) + end + + it 'uses the existing conversation even when it is resolved' do + # Create a contact and a resolved conversation + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { 'text' => 'first message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: params).perform + + # Mark the conversation as resolved + conversation = telegram_channel.inbox.conversations.last + conversation.update(status: :resolved) + + # Send a new message + new_params = { + 'update_id' => 2_342_342_343_243, + 'message' => { 'text' => 'second message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: new_params).perform + + # Should use the same conversation + expect(telegram_channel.inbox.conversations.count).to eq(1) + expect(telegram_channel.inbox.conversations.last.messages.count).to eq(2) + expect(telegram_channel.inbox.conversations.last.messages.last.content).to eq('second message') + end + end end context 'when lock to single conversation is enabled' do diff --git a/spec/services/tiktok/message_service_spec.rb b/spec/services/tiktok/message_service_spec.rb index fbef64336..ee7b113ac 100644 --- a/spec/services/tiktok/message_service_spec.rb +++ b/spec/services/tiktok/message_service_spec.rb @@ -6,8 +6,29 @@ RSpec.describe Tiktok::MessageService do let(:inbox) { channel.inbox } let(:contact) { create(:contact, account: account) } let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') } + let(:text_content) do + { + type: 'text', + message_id: 'tt-msg-lock', + timestamp: 1_700_000_000_000, + conversation_id: 'tt-conv-1', + text: { body: 'Hello from TikTok' }, + from: 'Alice', + from_user: { id: 'user-1' }, + to: 'Biz', + to_user: { id: 'biz-123' } + }.deep_symbolize_keys + end describe '#perform' do + subject(:perform_text_message) do + service = described_class.new(channel: channel, content: current_content) + allow(service).to receive(:create_contact_inbox).and_return(contact_inbox) + service.perform + end + + let(:current_content) { text_content } + it 'creates an incoming text message' do content = { type: 'text', @@ -113,5 +134,31 @@ RSpec.describe Tiktok::MessageService do ensure tempfile.close! end + + context 'when lock_to_single_conversation is enabled' do + it 'reuses the last resolved conversation' do + inbox.update!(lock_to_single_conversation: true) + resolved_conversation = create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :resolved) + + perform_text_message + + expect(inbox.conversations.count).to eq(1) + expect(resolved_conversation.reload.messages.last.content).to eq('Hello from TikTok') + end + end + + context 'when lock_to_single_conversation is disabled' do + let(:current_content) { text_content.merge(message_id: 'tt-msg-lock-2') } + + it 'creates a new conversation if the previous one is resolved' do + inbox.update!(lock_to_single_conversation: false) + create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :resolved) + + perform_text_message + + expect(inbox.conversations.count).to eq(2) + expect(inbox.conversations.last.messages.last.content).to eq('Hello from TikTok') + end + end end end diff --git a/spec/services/tiktok/read_status_service_spec.rb b/spec/services/tiktok/read_status_service_spec.rb index f0ec116ff..3c44cc738 100644 --- a/spec/services/tiktok/read_status_service_spec.rb +++ b/spec/services/tiktok/read_status_service_spec.rb @@ -31,5 +31,31 @@ RSpec.describe Tiktok::ReadStatusService do expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, kind_of(Time)) end + + it 'updates the latest active conversation when lock_to_single_conversation is disabled' do + allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later) + + inbox.update!(lock_to_single_conversation: false) + conversation.update!(status: :resolved) + active_conversation = create( + :conversation, + account: account, + inbox: inbox, + contact: contact, + contact_inbox: contact_inbox, + status: :open, + additional_attributes: { conversation_id: 'tt-conv-1' } + ) + + content = { + conversation_id: 'tt-conv-1', + read: { last_read_timestamp: 1_700_000_000_000 }, + from_user: { id: 'user-1' } + }.deep_symbolize_keys + + described_class.new(channel: channel, content: content).perform + + expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(active_conversation.id, kind_of(Time)) + end end end