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