- {{ $t('SURVEY.DESCRIPTION', { inboxName }) }}
+ {{ messageContent }}
+
diff --git a/app/models/article.rb b/app/models/article.rb
index 48e0529a2..14450b574 100644
--- a/app/models/article.rb
+++ b/app/models/article.rb
@@ -33,6 +33,7 @@
#
class Article < ApplicationRecord
include PgSearch::Model
+ include LlmFormattable
has_many :associated_articles,
class_name: :Article,
diff --git a/app/models/concerns/llm_formattable.rb b/app/models/concerns/llm_formattable.rb
index 086ccc46a..0cdc76718 100644
--- a/app/models/concerns/llm_formattable.rb
+++ b/app/models/concerns/llm_formattable.rb
@@ -1,7 +1,7 @@
module LlmFormattable
extend ActiveSupport::Concern
- def to_llm_text
- LlmFormatter::LlmTextFormatterService.new(self).format
+ def to_llm_text(config = {})
+ LlmFormatter::LlmTextFormatterService.new(self).format(config)
end
end
diff --git a/app/models/inbox.rb b/app/models/inbox.rb
index 20c8dc610..f1343a352 100644
--- a/app/models/inbox.rb
+++ b/app/models/inbox.rb
@@ -9,6 +9,7 @@
# auto_assignment_config :jsonb
# business_name :string
# channel_type :string
+# csat_config :jsonb not null
# csat_survey_enabled :boolean default(FALSE)
# email_address :string
# enable_auto_assignment :boolean default(TRUE)
diff --git a/app/services/llm_formatter/article_llm_formatter.rb b/app/services/llm_formatter/article_llm_formatter.rb
new file mode 100644
index 000000000..5df7976b7
--- /dev/null
+++ b/app/services/llm_formatter/article_llm_formatter.rb
@@ -0,0 +1,22 @@
+class LlmFormatter::ArticleLlmFormatter
+ attr_reader :article
+
+ def initialize(article)
+ @article = article
+ end
+
+ def format(*)
+ <<~TEXT
+ Title: #{article.title}
+ ID: #{article.id}
+ Status: #{article.status}
+ Category: #{article.category&.name || 'Uncategorized'}
+ Author: #{article.author&.name || 'Unknown'}
+ Views: #{article.views}
+ Created At: #{article.created_at}
+ Updated At: #{article.updated_at}
+ Content:
+ #{article.content}
+ TEXT
+ end
+end
diff --git a/app/services/llm_formatter/contact_llm_formatter.rb b/app/services/llm_formatter/contact_llm_formatter.rb
index 9dcfcd299..586f8743b 100644
--- a/app/services/llm_formatter/contact_llm_formatter.rb
+++ b/app/services/llm_formatter/contact_llm_formatter.rb
@@ -1,5 +1,5 @@
class LlmFormatter::ContactLlmFormatter < LlmFormatter::DefaultLlmFormatter
- def format
+ def format(*)
sections = []
sections << "Contact ID: ##{@record.id}"
sections << 'Contact Attributes:'
diff --git a/app/services/llm_formatter/conversation_llm_formatter.rb b/app/services/llm_formatter/conversation_llm_formatter.rb
index 07afc2cac..1444d75c1 100644
--- a/app/services/llm_formatter/conversation_llm_formatter.rb
+++ b/app/services/llm_formatter/conversation_llm_formatter.rb
@@ -1,5 +1,5 @@
class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
- def format
+ def format(config = {})
sections = []
sections << "Conversation ID: ##{@record.display_id}"
sections << "Channel: #{@record.inbox.channel.name}"
@@ -10,6 +10,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
'No messages in this conversation'
end
+ sections << "Contact Details: #{@record.contact.to_llm_text}" if config[:include_contact_details]
sections.join("\n")
end
diff --git a/app/services/llm_formatter/default_llm_formatter.rb b/app/services/llm_formatter/default_llm_formatter.rb
index b82a039cf..3ac1fa7f4 100644
--- a/app/services/llm_formatter/default_llm_formatter.rb
+++ b/app/services/llm_formatter/default_llm_formatter.rb
@@ -3,7 +3,7 @@ class LlmFormatter::DefaultLlmFormatter
@record = record
end
- def format
+ def format(*)
# override this
end
end
diff --git a/app/services/llm_formatter/llm_text_formatter_service.rb b/app/services/llm_formatter/llm_text_formatter_service.rb
index 0f1f37cf8..198f09d58 100644
--- a/app/services/llm_formatter/llm_text_formatter_service.rb
+++ b/app/services/llm_formatter/llm_text_formatter_service.rb
@@ -3,9 +3,9 @@ class LlmFormatter::LlmTextFormatterService
@record = record
end
- def format
+ def format(config = {})
formatter_class = find_formatter
- formatter_class.new(@record).format
+ formatter_class.new(@record).format(config)
end
private
diff --git a/app/services/message_templates/template/csat_survey.rb b/app/services/message_templates/template/csat_survey.rb
index 4171367c7..3a7ca2605 100644
--- a/app/services/message_templates/template/csat_survey.rb
+++ b/app/services/message_templates/template/csat_survey.rb
@@ -2,6 +2,8 @@ class MessageTemplates::Template::CsatSurvey
pattr_initialize [:conversation!]
def perform
+ return unless should_send_csat_survey?
+
ActiveRecord::Base.transaction do
conversation.messages.create!(csat_survey_message_params)
end
@@ -9,8 +11,47 @@ class MessageTemplates::Template::CsatSurvey
private
- delegate :contact, :account, to: :conversation
- delegate :inbox, to: :message
+ delegate :contact, :account, :inbox, to: :conversation
+ delegate :csat_config, to: :inbox
+
+ def should_send_csat_survey?
+ return true unless survey_rules_configured?
+
+ labels = conversation.label_list
+
+ return true if rule_values.empty?
+
+ case rule_operator
+ when 'contains'
+ rule_values.any? { |label| labels.include?(label) }
+ when 'does_not_contain'
+ rule_values.none? { |label| labels.include?(label) }
+ else
+ true
+ end
+ end
+
+ def survey_rules_configured?
+ return false if csat_config.blank?
+ return false if csat_config['survey_rules'].blank?
+ return false if rule_values.empty?
+
+ true
+ end
+
+ def rule_operator
+ csat_config.dig('survey_rules', 'operator') || 'contains'
+ end
+
+ def rule_values
+ csat_config.dig('survey_rules', 'values') || []
+ end
+
+ def message_content
+ return I18n.t('conversations.templates.csat_input_message_body') if csat_config.blank? || csat_config['message'].blank?
+
+ csat_config['message']
+ end
def csat_survey_message_params
{
@@ -18,7 +59,18 @@ class MessageTemplates::Template::CsatSurvey
inbox_id: @conversation.inbox_id,
message_type: :template,
content_type: :input_csat,
- content: I18n.t('conversations.templates.csat_input_message_body')
+ content: message_content,
+ content_attributes: content_attributes
+ }
+ end
+
+ def csat_config
+ inbox.csat_config || {}
+ end
+
+ def content_attributes
+ {
+ display_type: csat_config['display_type'] || 'emoji'
}
end
end
diff --git a/app/services/twilio/incoming_message_service.rb b/app/services/twilio/incoming_message_service.rb
index 5c5854ee6..7a335c87d 100644
--- a/app/services/twilio/incoming_message_service.rb
+++ b/app/services/twilio/incoming_message_service.rb
@@ -106,15 +106,22 @@ class Twilio::IncomingMessageService
end
def attach_files
- return if params[:MediaUrl0].blank?
+ num_media = params[:NumMedia].to_i
+ return if num_media.zero?
- attachment_file = download_attachment_file
+ num_media.times do |i|
+ media_url = params[:"MediaUrl#{i}"]
+ attach_single_file(media_url) if media_url.present?
+ end
+ end
+ def attach_single_file(media_url)
+ attachment_file = download_attachment_file(media_url)
return if attachment_file.blank?
@message.attachments.new(
account_id: @message.account_id,
- file_type: file_type(params[:MediaContentType0]),
+ file_type: file_type(attachment_file.content_type),
file: {
io: attachment_file,
filename: attachment_file.original_filename,
@@ -123,24 +130,22 @@ class Twilio::IncomingMessageService
)
end
- def download_attachment_file
- download_with_auth
+ def download_attachment_file(media_url)
+ download_with_auth(media_url)
rescue Down::Error, Down::ClientError => e
- handle_download_attachment_error(e)
+ handle_download_attachment_error(e, media_url)
end
- def download_with_auth
+ def download_with_auth(media_url)
Down.download(
- params[:MediaUrl0],
- # https://support.twilio.com/hc/en-us/articles/223183748-Protect-Media-Access-with-HTTP-Basic-Authentication-for-Programmable-Messaging
+ media_url,
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
)
end
- # This is just a temporary workaround since some users have not yet enabled media protection. We will remove this in the future.
- def handle_download_attachment_error(error)
+ def handle_download_attachment_error(error, media_url)
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying"
- Down.download(params[:MediaUrl0])
+ Down.download(media_url)
rescue StandardError => e
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
nil
diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder
index 0747924d9..c66a6eb78 100644
--- a/app/views/api/v1/models/_inbox.json.jbuilder
+++ b/app/views/api/v1/models/_inbox.json.jbuilder
@@ -8,6 +8,7 @@ json.greeting_message resource.greeting_message
json.working_hours_enabled resource.working_hours_enabled
json.enable_email_collect resource.enable_email_collect
json.csat_survey_enabled resource.csat_survey_enabled
+json.csat_config resource.csat_config
json.enable_auto_assignment resource.enable_auto_assignment
json.auto_assignment_config resource.auto_assignment_config
json.out_of_office_message resource.out_of_office_message
diff --git a/app/views/public/api/v1/models/_csat_survey.json.jbuilder b/app/views/public/api/v1/models/_csat_survey.json.jbuilder
index 134cb1af1..c5206e91b 100644
--- a/app/views/public/api/v1/models/_csat_survey.json.jbuilder
+++ b/app/views/public/api/v1/models/_csat_survey.json.jbuilder
@@ -1,5 +1,7 @@
json.id resource.id
json.csat_survey_response resource.csat_survey_response
+json.display_type resource.inbox.csat_config.try(:[], 'display_type') || 'emoji'
+json.content resource.inbox.csat_config.try(:[], 'message')
json.inbox_avatar_url resource.inbox.avatar_url
json.inbox_name resource.inbox.name
json.locale resource.account.locale
diff --git a/config/routes.rb b/config/routes.rb
index c623ff053..ee6ec5e8a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -58,9 +58,12 @@ Rails.application.routes.draw do
end
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
end
- resources :documents, only: [:index, :show, :create, :destroy]
resources :assistant_responses
resources :bulk_actions, only: [:create]
+ resources :copilot_threads, only: [:index] do
+ resources :copilot_messages, only: [:index]
+ end
+ resources :documents, only: [:index, :show, :create, :destroy]
end
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
delete :avatar, on: :member
diff --git a/db/migrate/20250512231036_create_copilot_threads.rb b/db/migrate/20250512231036_create_copilot_threads.rb
new file mode 100644
index 000000000..68686e066
--- /dev/null
+++ b/db/migrate/20250512231036_create_copilot_threads.rb
@@ -0,0 +1,14 @@
+class CreateCopilotThreads < ActiveRecord::Migration[7.0]
+ def change
+ create_table :copilot_threads do |t|
+ t.string :title, null: false
+ t.references :user, null: false, index: true
+ t.references :account, null: false, index: true
+ t.uuid :uuid, null: false, default: 'gen_random_uuid()'
+
+ t.timestamps
+ end
+
+ add_index :copilot_threads, :uuid, unique: true
+ end
+end
diff --git a/db/migrate/20250512231037_create_copilot_messages.rb b/db/migrate/20250512231037_create_copilot_messages.rb
new file mode 100644
index 000000000..fd03cc9f8
--- /dev/null
+++ b/db/migrate/20250512231037_create_copilot_messages.rb
@@ -0,0 +1,13 @@
+class CreateCopilotMessages < ActiveRecord::Migration[7.0]
+ def change
+ create_table :copilot_messages do |t|
+ t.references :copilot_thread, null: false, index: true
+ t.references :user, null: false, index: true
+ t.references :account, null: false, index: true
+ t.string :message_type, null: false
+ t.jsonb :message, null: false, default: {}
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20250514045638_add_csat_config_to_inboxes.rb b/db/migrate/20250514045638_add_csat_config_to_inboxes.rb
new file mode 100644
index 000000000..bb4fca964
--- /dev/null
+++ b/db/migrate/20250514045638_add_csat_config_to_inboxes.rb
@@ -0,0 +1,5 @@
+class AddCsatConfigToInboxes < ActiveRecord::Migration[7.0]
+ def change
+ add_column :inboxes, :csat_config, :jsonb, default: {}, null: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index d858899ab..9c78acd33 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.0].define(version: 2025_04_21_085134) do
+ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -575,6 +575,31 @@ ActiveRecord::Schema[7.0].define(version: 2025_04_21_085134) do
t.index ["waiting_since"], name: "index_conversations_on_waiting_since"
end
+ create_table "copilot_messages", force: :cascade do |t|
+ t.bigint "copilot_thread_id", null: false
+ t.bigint "user_id", null: false
+ t.bigint "account_id", null: false
+ t.string "message_type", null: false
+ t.jsonb "message", default: {}, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_copilot_messages_on_account_id"
+ t.index ["copilot_thread_id"], name: "index_copilot_messages_on_copilot_thread_id"
+ t.index ["user_id"], name: "index_copilot_messages_on_user_id"
+ end
+
+ create_table "copilot_threads", force: :cascade do |t|
+ t.string "title", null: false
+ t.bigint "user_id", null: false
+ t.bigint "account_id", null: false
+ t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_copilot_threads_on_account_id"
+ t.index ["user_id"], name: "index_copilot_threads_on_user_id"
+ t.index ["uuid"], name: "index_copilot_threads_on_uuid", unique: true
+ end
+
create_table "csat_survey_responses", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "conversation_id", null: false
@@ -704,6 +729,7 @@ ActiveRecord::Schema[7.0].define(version: 2025_04_21_085134) do
t.bigint "portal_id"
t.integer "sender_name_type", default: 0, null: false
t.string "business_name"
+ t.jsonb "csat_config", default: {}, null: false
t.index ["account_id"], name: "index_inboxes_on_account_id"
t.index ["channel_id", "channel_type"], name: "index_inboxes_on_channel_id_and_channel_type"
t.index ["portal_id"], name: "index_inboxes_on_portal_id"
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb
new file mode 100644
index 000000000..2a30fba48
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb
@@ -0,0 +1,25 @@
+class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController
+ before_action :current_account
+ before_action -> { check_authorization(Captain::Assistant) }
+ before_action :set_copilot_thread
+
+ def index
+ @copilot_messages = @copilot_thread
+ .copilot_messages
+ .order(created_at: :asc)
+ .page(permitted_params[:page] || 1)
+ .per(1000)
+ end
+
+ private
+
+ def set_copilot_thread
+ @copilot_thread = Current.account.copilot_threads.find_by!(
+ uuid: params[:copilot_thread_id], user_id: Current.user.id
+ )
+ end
+
+ def permitted_params
+ params.permit(:page)
+ end
+end
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb
new file mode 100644
index 000000000..e313f448c
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb
@@ -0,0 +1,19 @@
+class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController
+ before_action :current_account
+ before_action -> { check_authorization(Captain::Assistant) }
+
+ def index
+ @copilot_threads = Current.account.copilot_threads
+ .where(user_id: Current.user.id)
+ .includes(:user)
+ .order(created_at: :desc)
+ .page(permitted_params[:page] || 1)
+ .per(5)
+ end
+
+ private
+
+ def permitted_params
+ params.permit(:page)
+ end
+end
diff --git a/enterprise/app/models/copilot_message.rb b/enterprise/app/models/copilot_message.rb
new file mode 100644
index 000000000..16ae2c3c9
--- /dev/null
+++ b/enterprise/app/models/copilot_message.rb
@@ -0,0 +1,27 @@
+# == Schema Information
+#
+# Table name: copilot_messages
+#
+# id :bigint not null, primary key
+# message :jsonb not null
+# message_type :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+# copilot_thread_id :bigint not null
+# user_id :bigint not null
+#
+# Indexes
+#
+# index_copilot_messages_on_account_id (account_id)
+# index_copilot_messages_on_copilot_thread_id (copilot_thread_id)
+# index_copilot_messages_on_user_id (user_id)
+#
+class CopilotMessage < ApplicationRecord
+ belongs_to :copilot_thread
+ belongs_to :user
+ belongs_to :account
+
+ validates :message_type, presence: true, inclusion: { in: %w[user assistant assistant_thinking] }
+ validates :message, presence: true
+end
diff --git a/enterprise/app/models/copilot_thread.rb b/enterprise/app/models/copilot_thread.rb
new file mode 100644
index 000000000..865418ad7
--- /dev/null
+++ b/enterprise/app/models/copilot_thread.rb
@@ -0,0 +1,26 @@
+# == Schema Information
+#
+# Table name: copilot_threads
+#
+# id :bigint not null, primary key
+# title :string not null
+# uuid :uuid not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+# user_id :bigint not null
+#
+# Indexes
+#
+# index_copilot_threads_on_account_id (account_id)
+# index_copilot_threads_on_user_id (user_id)
+# index_copilot_threads_on_uuid (uuid) UNIQUE
+#
+class CopilotThread < ApplicationRecord
+ belongs_to :user
+ belongs_to :account
+ has_many :copilot_messages, dependent: :destroy
+
+ validates :title, presence: true
+ validates :uuid, presence: true, uniqueness: true
+end
diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb
index 4fcb9b34b..4a573a4c4 100644
--- a/enterprise/app/models/enterprise/concerns/account.rb
+++ b/enterprise/app/models/enterprise/concerns/account.rb
@@ -9,5 +9,7 @@ module Enterprise::Concerns::Account
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
+
+ has_many :copilot_threads, dependent: :destroy_async
end
end
diff --git a/enterprise/app/models/enterprise/concerns/user.rb b/enterprise/app/models/enterprise/concerns/user.rb
index 5d2687fbf..0e597b8d8 100644
--- a/enterprise/app/models/enterprise/concerns/user.rb
+++ b/enterprise/app/models/enterprise/concerns/user.rb
@@ -5,6 +5,8 @@ module Enterprise::Concerns::User
before_validation :ensure_installation_pricing_plan_quantity, on: :create
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
+ has_many :copilot_threads, dependent: :destroy_async
+ has_many :copilot_messages, dependent: :destroy_async
end
def ensure_installation_pricing_plan_quantity
diff --git a/enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder
new file mode 100644
index 000000000..ce0d5b175
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder
@@ -0,0 +1,8 @@
+json.payload do
+ json.array! @copilot_messages do |message|
+ json.id message.id
+ json.message message.message
+ json.message_type message.message_type
+ json.created_at message.created_at.to_i
+ end
+end
diff --git a/enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder
new file mode 100644
index 000000000..c06182ffd
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder
@@ -0,0 +1,12 @@
+json.payload do
+ json.array! @copilot_threads do |thread|
+ json.id thread.id
+ json.title thread.title
+ json.uuid thread.uuid
+ json.created_at thread.created_at.to_i
+ json.user do
+ json.id thread.user.id
+ json.name thread.user.name
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
index f8655da44..96272f9ac 100644
--- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
@@ -717,6 +717,94 @@ RSpec.describe 'Inboxes API', type: :request do
expect(email_channel.reload.smtp_authentication).to eq('plain')
end
end
+
+ context 'when handling CSAT configuration' do
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:csat_config) do
+ {
+ 'display_type' => 'emoji',
+ 'message' => 'How would you rate your experience?',
+ 'survey_rules' => {
+ 'operator' => 'contains',
+ 'values' => %w[support help]
+ }
+ }
+ end
+
+ it 'successfully updates the inbox with CSAT configuration' do
+ patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
+ params: {
+ csat_survey_enabled: true,
+ csat_config: csat_config
+ },
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+
+ context 'when CSAT is configured' do
+ before do
+ patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
+ params: {
+ csat_survey_enabled: true,
+ csat_config: csat_config
+ },
+ headers: admin.create_new_auth_token,
+ as: :json
+ end
+
+ it 'returns configured CSAT settings in inbox details' do
+ get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['csat_survey_enabled']).to be true
+
+ saved_config = json_response['csat_config']
+ expect(saved_config).to be_present
+ expect(saved_config['display_type']).to eq('emoji')
+ end
+
+ it 'returns configured CSAT message' do
+ get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ json_response = response.parsed_body
+ saved_config = json_response['csat_config']
+ expect(saved_config['message']).to eq('How would you rate your experience?')
+ end
+
+ it 'returns configured CSAT survey rules' do
+ get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ json_response = response.parsed_body
+ saved_config = json_response['csat_config']
+ expect(saved_config['survey_rules']['operator']).to eq('contains')
+ expect(saved_config['survey_rules']['values']).to match_array(%w[support help])
+ end
+
+ it 'includes CSAT configuration in inbox list' do
+ get "/api/v1/accounts/#{account.id}/inboxes",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ inbox_list = response.parsed_body
+ found_inbox = inbox_list['payload'].find { |i| i['id'] == inbox.id }
+
+ expect(found_inbox['csat_survey_enabled']).to be true
+ expect(found_inbox['csat_config']).to be_present
+ expect(found_inbox['csat_config']['display_type']).to eq('emoji')
+ end
+ end
+ end
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/agent_bot' do
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb
new file mode 100644
index 000000000..0ccca90c5
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb
@@ -0,0 +1,33 @@
+require 'rails_helper'
+
+RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :request do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account, role: :administrator) }
+ let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
+ let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, user: user, account: account) }
+
+ describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.uuid}/copilot_messages' do
+ context 'when it is an authenticated user' do
+ it 'returns all messages' do
+ get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.uuid}/copilot_messages",
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['payload'].length).to eq(1)
+ expect(json_response['payload'][0]['id']).to eq(copilot_message.id)
+ end
+ end
+
+ context 'when thread uuid is invalid' do
+ it 'returns not found error' do
+ get "/api/v1/accounts/#{account.id}/captain/copilot_threads/invalid-uuid/copilot_messages",
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb
new file mode 100644
index 000000000..8533a2d1d
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb
@@ -0,0 +1,50 @@
+require 'rails_helper'
+
+RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+
+ def json_response
+ JSON.parse(response.body, symbolize_names: true)
+ end
+
+ describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads' do
+ context 'when it is an un-authenticated user' do
+ it 'does not fetch copilot threads' do
+ get "/api/v1/accounts/#{account.id}/captain/copilot_threads",
+ as: :json
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'fetches copilot threads for the current user' do
+ # Create threads for the current agent
+ create_list(:captain_copilot_thread, 3, account: account, user: agent)
+ # Create threads for another user (should not be included)
+ create_list(:captain_copilot_thread, 2, account: account, user: admin)
+
+ get "/api/v1/accounts/#{account.id}/captain/copilot_threads",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:payload].length).to eq(3)
+
+ expect(json_response[:payload].map { |thread| thread[:user][:id] }.uniq).to eq([agent.id])
+ end
+
+ it 'returns threads in descending order of creation' do
+ threads = create_list(:captain_copilot_thread, 3, account: account, user: agent)
+
+ get "/api/v1/accounts/#{account.id}/captain/copilot_threads",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:payload].pluck(:id)).to eq(threads.reverse.pluck(:id))
+ end
+ end
+ end
+end
diff --git a/spec/factories/captain/copilot_message.rb b/spec/factories/captain/copilot_message.rb
new file mode 100644
index 000000000..78f9f202e
--- /dev/null
+++ b/spec/factories/captain/copilot_message.rb
@@ -0,0 +1,9 @@
+FactoryBot.define do
+ factory :captain_copilot_message, class: 'CopilotMessage' do
+ account
+ user
+ copilot_thread { association :captain_copilot_thread }
+ message { { content: 'This is a test message' } }
+ message_type { 'user' }
+ end
+end
diff --git a/spec/factories/captain/copilot_thread.rb b/spec/factories/captain/copilot_thread.rb
new file mode 100644
index 000000000..fee78a7e7
--- /dev/null
+++ b/spec/factories/captain/copilot_thread.rb
@@ -0,0 +1,8 @@
+FactoryBot.define do
+ factory :captain_copilot_thread, class: 'CopilotThread' do
+ account
+ user
+ title { Faker::Lorem.sentence }
+ uuid { SecureRandom.uuid }
+ end
+end
diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb
index 0dc17d472..161f3541d 100644
--- a/spec/models/article_spec.rb
+++ b/spec/models/article_spec.rb
@@ -167,4 +167,26 @@ RSpec.describe Article do
end
end
end
+
+ describe '#to_llm_text' do
+ it 'returns formatted article text' do
+ category = create(:category, name: 'Test Category', slug: 'test_category', portal_id: portal_1.id)
+ article = create(:article, title: 'Test Article', category_id: category.id, content: 'This is the content', portal_id: portal_1.id,
+ author_id: user.id)
+ expected_output = <<~TEXT
+ Title: #{article.title}
+ ID: #{article.id}
+ Status: #{article.status}
+ Category: #{category.name}
+ Author: #{user.name}
+ Views: #{article.views}
+ Created At: #{article.created_at}
+ Updated At: #{article.updated_at}
+ Content:
+ #{article.content}
+ TEXT
+
+ expect(article.to_llm_text).to eq(expected_output)
+ end
+ end
end
diff --git a/spec/services/llm_formatter/article_llm_formatter_spec.rb b/spec/services/llm_formatter/article_llm_formatter_spec.rb
new file mode 100644
index 000000000..0f47beddb
--- /dev/null
+++ b/spec/services/llm_formatter/article_llm_formatter_spec.rb
@@ -0,0 +1,44 @@
+require 'rails_helper'
+
+RSpec.describe LlmFormatter::ArticleLlmFormatter do
+ let(:account) { create(:account) }
+ let(:portal) { create(:portal, account: account) }
+ let(:category) { create(:category, slug: 'test_category', portal: portal, account: account) }
+ let(:author) { create(:user, account: account) }
+ let(:formatter) { described_class.new(article) }
+
+ describe '#format' do
+ context 'when article has all details' do
+ let(:article) do
+ create(:article,
+ slug: 'test_article',
+ portal: portal, category: category, author: author, views: 100, account: account)
+ end
+
+ it 'formats article details correctly' do
+ expected_output = <<~TEXT
+ Title: #{article.title}
+ ID: #{article.id}
+ Status: #{article.status}
+ Category: #{category.name}
+ Author: #{author.name}
+ Views: #{article.views}
+ Created At: #{article.created_at}
+ Updated At: #{article.updated_at}
+ Content:
+ #{article.content}
+ TEXT
+
+ expect(formatter.format).to eq(expected_output)
+ end
+ end
+
+ context 'when article has no category' do
+ let(:article) { create(:article, portal: portal, category: nil, author: author, account: account) }
+
+ it 'shows Uncategorized for category' do
+ expect(formatter.format).to include('Category: Uncategorized')
+ end
+ end
+ end
+end
diff --git a/spec/services/llm_formatter/contact_llm_formatter_spec.rb b/spec/services/llm_formatter/contact_llm_formatter_spec.rb
new file mode 100644
index 000000000..bf3345b98
--- /dev/null
+++ b/spec/services/llm_formatter/contact_llm_formatter_spec.rb
@@ -0,0 +1,78 @@
+require 'rails_helper'
+
+RSpec.describe LlmFormatter::ContactLlmFormatter do
+ let(:account) { create(:account) }
+ let(:contact) { create(:contact, account: account, name: 'John Doe', email: 'john@example.com', phone_number: '+1234567890') }
+ let(:formatter) { described_class.new(contact) }
+
+ describe '#format' do
+ context 'when contact has no notes' do
+ it 'formats contact details correctly' do
+ expected_output = [
+ "Contact ID: ##{contact.id}",
+ 'Contact Attributes:',
+ 'Name: John Doe',
+ 'Email: john@example.com',
+ 'Phone: +1234567890',
+ 'Location: ',
+ 'Country Code: ',
+ 'Contact Notes:',
+ 'No notes for this contact'
+ ].join("\n")
+
+ expect(formatter.format).to eq(expected_output)
+ end
+ end
+
+ context 'when contact has notes' do
+ before do
+ create(:note, account: account, contact: contact, content: 'First interaction')
+ create(:note, account: account, contact: contact, content: 'Follow up needed')
+ end
+
+ it 'includes notes in the output' do
+ expected_output = [
+ "Contact ID: ##{contact.id}",
+ 'Contact Attributes:',
+ 'Name: John Doe',
+ 'Email: john@example.com',
+ 'Phone: +1234567890',
+ 'Location: ',
+ 'Country Code: ',
+ 'Contact Notes:',
+ ' - First interaction',
+ ' - Follow up needed'
+ ].join("\n")
+
+ expect(formatter.format).to eq(expected_output)
+ end
+ end
+
+ context 'when contact has custom attributes' do
+ let!(:custom_attribute) do
+ create(:custom_attribute_definition, account: account, attribute_model: 'contact_attribute', attribute_display_name: 'Company')
+ end
+
+ before do
+ contact.update(custom_attributes: { custom_attribute.attribute_key => 'Acme Inc' })
+ end
+
+ it 'includes custom attributes in the output' do
+ expected_output = [
+ "Contact ID: ##{contact.id}",
+ 'Contact Attributes:',
+ 'Name: John Doe',
+ 'Email: john@example.com',
+ 'Phone: +1234567890',
+ 'Location: ',
+ 'Country Code: ',
+ 'Company: Acme Inc',
+ 'Contact Notes:',
+ 'No notes for this contact'
+ ].join("\n")
+
+ expect(formatter.format).to eq(expected_output)
+ end
+ end
+ end
+end
diff --git a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb
index 3838b8126..93fec14f7 100644
--- a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb
+++ b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb
@@ -47,5 +47,19 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
expect(formatter.format).to eq(expected_output)
end
end
+
+ context 'when include_contact_details is true' do
+ it 'includes contact details' do
+ expected_output = [
+ "Conversation ID: ##{conversation.display_id}",
+ "Channel: #{conversation.inbox.channel.name}",
+ 'Message History:',
+ 'No messages in this conversation',
+ "Contact Details: #{conversation.contact.to_llm_text}"
+ ].join("\n")
+
+ expect(formatter.format(include_contact_details: true)).to eq(expected_output)
+ end
+ end
end
end
diff --git a/spec/services/message_templates/template/csat_survey_spec.rb b/spec/services/message_templates/template/csat_survey_spec.rb
index dae44ca3e..a2cae684b 100644
--- a/spec/services/message_templates/template/csat_survey_spec.rb
+++ b/spec/services/message_templates/template/csat_survey_spec.rb
@@ -1,13 +1,100 @@
require 'rails_helper'
describe MessageTemplates::Template::CsatSurvey do
- context 'when this hook is called' do
- let(:conversation) { create(:conversation) }
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:service) { described_class.new(conversation: conversation) }
- it 'creates the out of office messages' do
- described_class.new(conversation: conversation).perform
- expect(conversation.messages.template.count).to eq(1)
- expect(conversation.messages.template.first.content_type).to eq('input_csat')
+ describe '#perform' do
+ context 'when no survey rules are configured' do
+ it 'creates a CSAT survey message' do
+ inbox.update(csat_config: {})
+
+ service.perform
+
+ expect(conversation.messages.template.count).to eq(1)
+ expect(conversation.messages.template.first.content_type).to eq('input_csat')
+ end
+ end
+ end
+
+ describe '#perform with contains operator' do
+ let(:csat_config) do
+ {
+ 'display_type' => 'emoji',
+ 'message' => 'Please rate your experience',
+ 'survey_rules' => {
+ 'operator' => 'contains',
+ 'values' => %w[support help]
+ }
+ }
+ end
+
+ before do
+ inbox.update(csat_config: csat_config)
+ end
+
+ context 'when conversation has matching labels' do
+ it 'creates a CSAT survey message' do
+ conversation.update(label_list: %w[support urgent])
+
+ service.perform
+
+ expect(conversation.messages.template.count).to eq(1)
+ message = conversation.messages.template.first
+ expect(message.content_type).to eq('input_csat')
+ expect(message.content).to eq('Please rate your experience')
+ expect(message.content_attributes['display_type']).to eq('emoji')
+ end
+ end
+
+ context 'when conversation has no matching labels' do
+ it 'does not create a CSAT survey message' do
+ conversation.update(label_list: %w[billing-support payment])
+
+ service.perform
+
+ expect(conversation.messages.template.count).to eq(0)
+ end
+ end
+ end
+
+ describe '#perform with does_not_contain operator' do
+ let(:csat_config) do
+ {
+ 'display_type' => 'emoji',
+ 'message' => 'Please rate your experience',
+ 'survey_rules' => {
+ 'operator' => 'does_not_contain',
+ 'values' => %w[support help]
+ }
+ }
+ end
+
+ before do
+ inbox.update(csat_config: csat_config)
+ end
+
+ context 'when conversation does not have matching labels' do
+ it 'creates a CSAT survey message' do
+ conversation.update(label_list: %w[billing payment])
+
+ service.perform
+
+ expect(conversation.messages.template.count).to eq(1)
+ expect(conversation.messages.template.first.content_type).to eq('input_csat')
+ end
+ end
+
+ context 'when conversation has matching labels' do
+ it 'does not create a CSAT survey message' do
+ conversation.update(label_list: %w[support urgent])
+
+ service.perform
+
+ expect(conversation.messages.template.count).to eq(0)
+ end
end
end
end
diff --git a/spec/services/twilio/incoming_message_service_spec.rb b/spec/services/twilio/incoming_message_service_spec.rb
index 5f4dcec69..c8812e45d 100644
--- a/spec/services/twilio/incoming_message_service_spec.rb
+++ b/spec/services/twilio/incoming_message_service_spec.rb
@@ -173,7 +173,7 @@ describe Twilio::IncomingMessageService do
context 'when a message with an attachment is received' do
before do
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
- .to_return(status: 200, body: 'image data', headers: {})
+ .to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
end
let(:params_with_attachment) do
@@ -203,7 +203,7 @@ describe Twilio::IncomingMessageService do
.to_raise(Down::Error.new('Download error'))
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
- .to_return(status: 200, body: 'image data', headers: {})
+ .to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
end
let(:params_with_attachment_error) do
@@ -229,5 +229,36 @@ describe Twilio::IncomingMessageService do
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
end
end
+
+ context 'when a message with multiple attachments is received' do
+ before do
+ stub_request(:get, 'https://chatwoot-assets.local/sample.png')
+ .to_return(status: 200, body: 'image data 1', headers: { 'Content-Type' => 'image/png' })
+ stub_request(:get, 'https://chatwoot-assets.local/sample.jpg')
+ .to_return(status: 200, body: 'image data 2', headers: { 'Content-Type' => 'image/jpeg' })
+ end
+
+ let(:params_with_multiple_attachments) do
+ {
+ SmsSid: 'SMxx',
+ From: '+12345',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: twilio_channel.messaging_service_sid,
+ Body: 'testing multiple media',
+ NumMedia: '2',
+ MediaContentType0: 'image/png',
+ MediaUrl0: 'https://chatwoot-assets.local/sample.png',
+ MediaContentType1: 'image/jpeg',
+ MediaUrl1: 'https://chatwoot-assets.local/sample.jpg'
+ }
+ end
+
+ it 'creates a new message with multiple media attachments in existing conversation' do
+ described_class.new(params: params_with_multiple_attachments).perform
+ expect(conversation.reload.messages.last.content).to eq('testing multiple media')
+ expect(conversation.reload.messages.last.attachments.count).to eq(2)
+ expect(conversation.reload.messages.last.attachments.map(&:file_type)).to contain_exactly('image', 'image')
+ end
+ end
end
end