- {{ $t('CSAT_REPORTS.NO_RECORDS') }}
+
+
+
+
+
+
+ |
+ {{ header.column.columnDef.header }}
+ |
+
+
+
+
+
+ |
+
+ |
+
+
+
+ {{ $t(getRatingData(row.rating).translationKey) }}
+
+
+ |
+
+
+ {{ $t('CSAT_REPORTS.NO_FEEDBACK') }}
+
+
+
+
+ |
+
+
+
+ {{ $t('CSAT_REPORTS.NO_AGENT') }}
+
+ |
+
+
+
+
+ |
+
+
+ |
+
+ |
+
+
+
+
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTableLoader.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTableLoader.vue
new file mode 100644
index 000000000..a24523017
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTableLoader.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/CSATMetrics.spec.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/CSATMetrics.spec.js
index cfaa4cd51..1e743cfc9 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/CSATMetrics.spec.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/CSATMetrics.spec.js
@@ -6,7 +6,6 @@ describe('CsatMetrics.vue', () => {
let getters;
let store;
let wrapper;
- const filters = { rating: 3 };
beforeEach(() => {
getters = {
@@ -18,8 +17,16 @@ describe('CsatMetrics.vue', () => {
4: 30,
5: 10,
}),
+ 'csat/getRatingCount': () => ({
+ 1: 10,
+ 2: 20,
+ 3: 30,
+ 4: 30,
+ 5: 10,
+ }),
'csat/getSatisfactionScore': () => 85,
'csat/getResponseRate': () => 90,
+ 'csat/getUIFlags': () => ({ isFetchingMetrics: false }),
};
store = createStore({
@@ -28,40 +35,31 @@ describe('CsatMetrics.vue', () => {
wrapper = shallowMount(CsatMetrics, {
global: {
- plugins: [store], // Ensure the store is injected here
+ plugins: [store],
mocks: {
- $t: msg => msg, // mock translation function
+ $t: msg => msg,
},
stubs: {
- CsatMetricCard: '
',
- BarChart: '
',
+ CsatMetricCard: true,
+ CsatRatingDistribution: true,
},
},
- props: { filters },
});
});
it('computes response count correctly', () => {
expect(wrapper.vm.responseCount).toBe('100');
- expect(wrapper.html()).toMatchSnapshot();
});
- it('formats values to percent correctly', () => {
- expect(wrapper.vm.formatToPercent(85)).toBe('85%');
- expect(wrapper.vm.formatToPercent(null)).toBe('--');
+ it('renders metric cards with correct values', () => {
+ const metricCards = wrapper.findAllComponents({ name: 'CsatMetricCard' });
+ expect(metricCards).toHaveLength(3);
});
- it('maps rating value to emoji correctly', () => {
- const rating = wrapper.vm.csatRatings[0]; // assuming this is { value: 1, emoji: '😡' }
- expect(wrapper.vm.ratingToEmoji(rating.value)).toBe(rating.emoji);
- });
-
- it('hides report card if rating filter is enabled', () => {
- expect(wrapper.html()).not.toContain('bar-chart-stub');
- });
-
- it('shows report card if rating filter is not enabled', async () => {
- await wrapper.setProps({ filters: {} });
- expect(wrapper.html()).toContain('bar-chart-stub');
+ it('renders rating distribution component', () => {
+ const distribution = wrapper.findComponent({
+ name: 'CsatRatingDistribution',
+ });
+ expect(distribution.exists()).toBe(true);
});
});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/__snapshots__/CSATMetrics.spec.js.snap b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/__snapshots__/CSATMetrics.spec.js.snap
deleted file mode 100644
index 1f1e80f83..000000000
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/__snapshots__/CSATMetrics.spec.js.snap
+++ /dev/null
@@ -1,10 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`CsatMetrics.vue > computes response count correctly 1`] = `
-"
-
-
-
-
-
"
-`;
diff --git a/app/javascript/dashboard/store/modules/csat.js b/app/javascript/dashboard/store/modules/csat.js
index 2cf929862..b5b3e8c53 100644
--- a/app/javascript/dashboard/store/modules/csat.js
+++ b/app/javascript/dashboard/store/modules/csat.js
@@ -82,6 +82,9 @@ export const getters = {
),
};
},
+ getRatingCount(_state) {
+ return _state.metrics.ratingsCount;
+ },
};
export const actions = {
@@ -115,6 +118,13 @@ export const actions = {
});
});
},
+ update: async ({ commit }, { id, reviewNotes }) => {
+ const response = await CSATReports.update(id, {
+ csat_review_notes: reviewNotes,
+ });
+ commit(types.UPDATE_CSAT_RESPONSE, response.data);
+ return response.data;
+ },
};
export const mutations = {
@@ -144,6 +154,7 @@ export const mutations = {
};
_state.metrics.totalSentMessagesCount = totalSentMessagesCount || 0;
},
+ [types.UPDATE_CSAT_RESPONSE]: MutationHelpers.update,
};
export default {
diff --git a/app/javascript/dashboard/store/modules/specs/csat/getters.spec.js b/app/javascript/dashboard/store/modules/specs/csat/getters.spec.js
index 5a9057b70..f649d7d21 100644
--- a/app/javascript/dashboard/store/modules/specs/csat/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/csat/getters.spec.js
@@ -86,4 +86,19 @@ describe('#getters', () => {
})
).toEqual('50.00');
});
+
+ it('getRatingCount', () => {
+ const state = {
+ metrics: {
+ ratingsCount: { 1: 10, 2: 20, 3: 15, 4: 3, 5: 2 },
+ },
+ };
+ expect(getters.getRatingCount(state)).toEqual({
+ 1: 10,
+ 2: 20,
+ 3: 15,
+ 4: 3,
+ 5: 2,
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 48e6babdc..b76867360 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -241,6 +241,7 @@ export default {
SET_CSAT_RESPONSE_UI_FLAG: 'SET_CSAT_RESPONSE_UI_FLAG',
SET_CSAT_RESPONSE: 'SET_CSAT_RESPONSE',
SET_CSAT_RESPONSE_METRICS: 'SET_CSAT_RESPONSE_METRICS',
+ UPDATE_CSAT_RESPONSE: 'UPDATE_CSAT_RESPONSE',
// Custom Attributes
SET_CUSTOM_ATTRIBUTE_UI_FLAG: 'SET_CUSTOM_ATTRIBUTE_UI_FLAG',
diff --git a/app/javascript/shared/constants/openai.js b/app/javascript/shared/constants/openai.js
deleted file mode 100644
index 8c5adb875..000000000
--- a/app/javascript/shared/constants/openai.js
+++ /dev/null
@@ -1,11 +0,0 @@
-export const OPEN_AI_OPTIONS = {
- IMPROVE_WRITING: 'improve_writing',
- FIX_SPELLING_GRAMMAR: 'fix_spelling_grammar',
- SHORTEN: 'shorten',
- EXPAND: 'expand',
- MAKE_FRIENDLY: 'make_friendly',
- MAKE_FORMAL: 'make_formal',
- SIMPLIFY: 'simplify',
- REPLY_SUGGESTION: 'reply_suggestion',
- SUMMARIZE: 'summarize',
-};
diff --git a/app/jobs/conversations/resolution_job.rb b/app/jobs/conversations/resolution_job.rb
index d8f34f755..7f61f22ab 100644
--- a/app/jobs/conversations/resolution_job.rb
+++ b/app/jobs/conversations/resolution_job.rb
@@ -16,10 +16,12 @@ class Conversations::ResolutionJob < ApplicationJob
private
def conversation_scope(account)
- if account.auto_resolve_ignore_waiting
- account.conversations.resolvable_not_waiting(account.auto_resolve_after)
- else
- account.conversations.resolvable_all(account.auto_resolve_after)
- end
+ base_scope = if account.auto_resolve_ignore_waiting
+ account.conversations.resolvable_not_waiting(account.auto_resolve_after)
+ else
+ account.conversations.resolvable_all(account.auto_resolve_after)
+ end
+ # Exclude orphan conversations where contact was deleted but conversation cleanup is pending
+ base_scope.where.not(contact_id: nil)
end
end
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index 7318cd978..5905c54f7 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -34,6 +34,7 @@ class Channel::Whatsapp < ApplicationRecord
after_create :sync_templates
before_destroy :teardown_webhooks
+ after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
def name
'Whatsapp'
@@ -86,4 +87,10 @@ class Channel::Whatsapp < ApplicationRecord
def teardown_webhooks
Whatsapp::WebhookTeardownService.new(self).perform
end
+
+ def should_auto_setup_webhooks?
+ # Only auto-setup webhooks for whatsapp_cloud provider with manual setup
+ # Embedded signup calls setup_webhooks explicitly in EmbeddedSignupService
+ provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup'
+ end
end
diff --git a/app/models/concerns/conversation_mute_helpers.rb b/app/models/concerns/conversation_mute_helpers.rb
index c6ea4c7b1..ebc0542d2 100644
--- a/app/models/concerns/conversation_mute_helpers.rb
+++ b/app/models/concerns/conversation_mute_helpers.rb
@@ -2,17 +2,21 @@ module ConversationMuteHelpers
extend ActiveSupport::Concern
def mute!
+ return unless contact
+
resolved!
contact.update(blocked: true)
create_muted_message
end
def unmute!
+ return unless contact
+
contact.update(blocked: false)
create_unmuted_message
end
def muted?
- contact.blocked?
+ contact&.blocked? || false
end
end
diff --git a/app/models/csat_survey_response.rb b/app/models/csat_survey_response.rb
index 7bcc25d58..804dfd4b7 100644
--- a/app/models/csat_survey_response.rb
+++ b/app/models/csat_survey_response.rb
@@ -27,6 +27,7 @@ class CsatSurveyResponse < ApplicationRecord
belongs_to :contact
belongs_to :message
belongs_to :assigned_agent, class_name: 'User', optional: true, inverse_of: :csat_survey_responses
+ belongs_to :review_notes_updated_by, class_name: 'User', optional: true
validates :rating, presence: true, inclusion: { in: [1, 2, 3, 4, 5] }
validates :account_id, presence: true
diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb
index 97d3f91ae..518b405da 100644
--- a/app/models/integrations/hook.rb
+++ b/app/models/integrations/hook.rb
@@ -64,13 +64,10 @@ class Integrations::Hook < ApplicationRecord
update(status: 'disabled')
end
- def process_event(event)
- case app_id
- when 'openai'
- Integrations::Openai::ProcessorService.new(hook: self, event: event).perform if app_id == 'openai'
- else
- { error: 'No processor found' }
- end
+ def process_event(_event)
+ # OpenAI integration migrated to Captain::EditorService
+ # Other integrations (slack, dialogflow, etc.) handled via HookJob
+ { error: 'No processor found' }
end
def feature_allowed?
diff --git a/app/models/user.rb b/app/models/user.rb
index cc25357f6..b14bcd158 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -90,6 +90,8 @@ class User < ApplicationRecord
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
alias_attribute :conversations, :assigned_conversations
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
+ has_many :reviewed_csat_survey_responses, foreign_key: 'review_notes_updated_by_id', class_name: 'CsatSurveyResponse',
+ dependent: :nullify, inverse_of: :review_notes_updated_by
has_many :conversation_participants, dependent: :destroy_async
has_many :participating_conversations, through: :conversation_participants, source: :conversation
diff --git a/app/policies/captain/tasks_policy.rb b/app/policies/captain/tasks_policy.rb
new file mode 100644
index 000000000..997b8fcda
--- /dev/null
+++ b/app/policies/captain/tasks_policy.rb
@@ -0,0 +1,21 @@
+class Captain::TasksPolicy < ApplicationPolicy
+ def rewrite?
+ true
+ end
+
+ def summarize?
+ true
+ end
+
+ def reply_suggestion?
+ true
+ end
+
+ def label_suggestion?
+ true
+ end
+
+ def follow_up?
+ true
+ end
+end
diff --git a/app/services/csat_survey_service.rb b/app/services/csat_survey_service.rb
index 6c8a288b8..cc38b820b 100644
--- a/app/services/csat_survey_service.rb
+++ b/app/services/csat_survey_service.rb
@@ -20,7 +20,7 @@ class CsatSurveyService
delegate :inbox, :contact, to: :conversation
def should_send_csat_survey?
- conversation_allows_csat? && csat_enabled? && !csat_already_sent?
+ conversation_allows_csat? && csat_enabled? && !csat_already_sent? && csat_allowed_by_survey_rules?
end
def conversation_allows_csat?
@@ -39,6 +39,37 @@ class CsatSurveyService
conversation.can_reply?
end
+ def csat_allowed_by_survey_rules?
+ 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?
+
+ rule_values.any?
+ end
+
+ def rule_operator
+ csat_config.dig('survey_rules', 'operator') || 'contains'
+ end
+
+ def rule_values
+ csat_config.dig('survey_rules', 'values') || []
+ end
+
def whatsapp_channel?
inbox.channel_type == 'Channel::Whatsapp'
end
@@ -113,6 +144,10 @@ class CsatSurveyService
)
end
+ def csat_config
+ inbox.csat_config || {}
+ end
+
def send_twilio_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
content_sid = template_config['content_sid']
diff --git a/app/services/llm_formatter/conversation_llm_formatter.rb b/app/services/llm_formatter/conversation_llm_formatter.rb
index 38d7c9e26..1e71ccbbc 100644
--- a/app/services/llm_formatter/conversation_llm_formatter.rb
+++ b/app/services/llm_formatter/conversation_llm_formatter.rb
@@ -26,10 +26,18 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
def build_messages(config = {})
return "No messages in this conversation\n" if @record.messages.empty?
- message_text = ''
- messages = @record.messages.where.not(message_type: :activity).order(created_at: :asc)
+ messages = @record.messages.where.not(message_type: [:activity, :template])
- messages.each do |message|
+ if config[:token_limit]
+ build_limited_messages(messages, config)
+ else
+ build_all_messages(messages, config)
+ end
+ end
+
+ def build_all_messages(messages, config)
+ message_text = ''
+ messages.order(created_at: :asc).each do |message|
# Skip private messages unless explicitly included in config
next if message.private? && !config[:include_private_messages]
@@ -38,6 +46,24 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
message_text
end
+ def build_limited_messages(messages, config)
+ selected = []
+ character_count = 0
+
+ messages.reorder(created_at: :desc).each do |message|
+ # Skip private messages unless explicitly included in config
+ next if message.private? && !config[:include_private_messages]
+
+ formatted = format_message(message)
+ break if character_count + formatted.length > config[:token_limit]
+
+ selected.prepend(formatted)
+ character_count += formatted.length
+ end
+
+ selected.join
+ end
+
def format_message(message)
sender = case message.sender_type
when 'User'
diff --git a/app/services/message_templates/template/csat_survey.rb b/app/services/message_templates/template/csat_survey.rb
index dd9cf3bd6..4fcef3e87 100644
--- a/app/services/message_templates/template/csat_survey.rb
+++ b/app/services/message_templates/template/csat_survey.rb
@@ -2,8 +2,6 @@ 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
@@ -13,39 +11,6 @@ class MessageTemplates::Template::CsatSurvey
delegate :contact, :account, :inbox, to: :conversation
- 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?
diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb
index 4379d0b74..52273bc5d 100644
--- a/app/services/whatsapp/embedded_signup_service.rb
+++ b/app/services/whatsapp/embedded_signup_service.rb
@@ -16,6 +16,10 @@ class Whatsapp::EmbeddedSignupService
validate_token_access(access_token)
channel = create_or_reauthorize_channel(access_token, phone_info)
+ # NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because:
+ # 1. Reauthorization flow updates an existing channel (not a create), so after_commit on: :create won't trigger
+ # 2. We need to run check_channel_health_and_prompt_reauth after webhook setup completes
+ # 3. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
channel.setup_webhooks
check_channel_health_and_prompt_reauth(channel)
channel
diff --git a/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb b/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb
index f8ac8c85a..164c3ac12 100644
--- a/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb
+++ b/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb
@@ -10,10 +10,7 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB
def download_attachment_file(attachment_payload)
url_response = HTTParty.get(
- inbox.channel.media_url(
- attachment_payload[:id],
- inbox.channel.provider_config['phone_number_id']
- ),
+ inbox.channel.media_url(attachment_payload[:id]),
headers: inbox.channel.api_headers
)
# This url response will be failure if the access token has expired.
diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
index 6f2ead579..5b4c26196 100644
--- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb
+++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
@@ -75,10 +75,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
csat_template_service.get_template_status(template_name)
end
- def media_url(media_id, phone_number_id = nil)
- url = "#{api_base_path}/v13.0/#{media_id}"
- url += "?phone_number_id=#{phone_number_id}" if phone_number_id
- url
+ def media_url(media_id)
+ "#{api_base_path}/v13.0/#{media_id}"
end
private
diff --git a/app/views/api/v1/accounts/csat_survey_responses/download.csv.erb b/app/views/api/v1/accounts/csat_survey_responses/download.csv.erb
index 5c0524480..ba17c175b 100644
--- a/app/views/api/v1/accounts/csat_survey_responses/download.csv.erb
+++ b/app/views/api/v1/accounts/csat_survey_responses/download.csv.erb
@@ -1,5 +1,5 @@
-<%=
- CSV.generate_line([
+<%
+ headers = [
I18n.t('reports.csat.headers.agent_name'),
I18n.t('reports.csat.headers.rating'),
I18n.t('reports.csat.headers.feedback'),
@@ -8,24 +8,28 @@
I18n.t('reports.csat.headers.contact_phone_number'),
I18n.t('reports.csat.headers.link_to_the_conversation'),
I18n.t('reports.csat.headers.recorded_at')
- ])
+ ]
+ headers << I18n.t('reports.csat.headers.review_notes') if ChatwootApp.enterprise?
-%>
+<%= CSV.generate_line(headers) -%>
<% @csat_survey_responses.each do |csat_response| %>
<% assigned_agent = csat_response.assigned_agent %>
<% contact = csat_response.contact %>
<% conversation = csat_response.conversation %>
-<%=
- CSV.generate_line([
+<%
+ row = [
assigned_agent ? "#{assigned_agent.name} (#{assigned_agent.email})" : nil,
csat_response.rating,
- csat_response.feedback_message.present? ? csat_response.feedback_message : nil,
- contact&.name.present? ? contact&.name: nil,
- contact&.email.present? ? contact&.email: nil,
- contact&.phone_number.present? ? contact&.phone_number: nil,
- conversation ? app_account_conversation_url(account_id: Current.account.id, id: conversation.display_id): nil,
- csat_response.created_at,
-]).html_safe
+ csat_response.feedback_message.presence,
+ contact&.name.presence,
+ contact&.email.presence,
+ contact&.phone_number.presence,
+ conversation ? app_account_conversation_url(account_id: Current.account.id, id: conversation.display_id) : nil,
+ csat_response.created_at
+ ]
+ row << csat_response.csat_review_notes if ChatwootApp.enterprise?
-%>
+<%= CSV.generate_line(row).html_safe -%>
<% end %>
<%=
CSV.generate_line([
diff --git a/app/views/api/v1/accounts/csat_survey_responses/update.json.jbuilder b/app/views/api/v1/accounts/csat_survey_responses/update.json.jbuilder
new file mode 100644
index 000000000..065eb237b
--- /dev/null
+++ b/app/views/api/v1/accounts/csat_survey_responses/update.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/csat_survey_response', formats: [:json], resource: @csat_survey_response
diff --git a/app/views/api/v1/models/_csat_survey_response.json.jbuilder b/app/views/api/v1/models/_csat_survey_response.json.jbuilder
index 3470c4646..fbba50aa7 100644
--- a/app/views/api/v1/models/_csat_survey_response.json.jbuilder
+++ b/app/views/api/v1/models/_csat_survey_response.json.jbuilder
@@ -1,6 +1,14 @@
json.id resource.id
json.rating resource.rating
json.feedback_message resource.feedback_message
+json.csat_review_notes resource.csat_review_notes
+json.review_notes_updated_at resource.review_notes_updated_at&.to_i
+if resource.review_notes_updated_by
+ json.review_notes_updated_by do
+ json.id resource.review_notes_updated_by.id
+ json.name resource.review_notes_updated_by.name
+ end
+end
json.account_id resource.account_id
json.message_id resource.message_id
if resource.contact
diff --git a/config/app.yml b/config/app.yml
index 65d2e7886..c81f2102f 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.9.2'
+ version: '4.10.1'
development:
<<: *shared
diff --git a/config/features.yml b/config/features.yml
index 6792cbb07..703d3cb8c 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -230,3 +230,10 @@
- name: channel_tiktok
display_name: TikTok Channel
enabled: true
+- name: csat_review_notes
+ display_name: CSAT Review Notes
+ enabled: false
+ premium: true
+- name: captain_tasks
+ display_name: Captain Tasks
+ enabled: true
diff --git a/config/locales/en.yml b/config/locales/en.yml
index bacd007bb..f8d5b119e 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -202,6 +202,7 @@ en:
rating: Rating
feedback: Feedback Comment
recorded_at: Recorded date
+ review_notes: Review Notes
notifications:
notification_title:
conversation_creation: 'A conversation (#%{display_id}) has been created in %{inbox_name}'
@@ -344,6 +345,9 @@ en:
copilot_message_required: Message is required
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
+ upgrade: 'Upgrade your plan to enable Captain AI'
+ disabled: 'Captain AI is disabled for this account.'
+ api_key_missing: 'Captain AI API key is not configured.'
copilot:
using_tool: 'Using tool %{function_name}'
completed_tool_call: 'Completed %{function_name} tool call'
diff --git a/config/routes.rb b/config/routes.rb
index bdc8e4fe6..95b77d323 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -73,6 +73,13 @@ Rails.application.routes.draw do
end
resources :custom_tools
resources :documents, only: [:index, :show, :create, :destroy]
+ resource :tasks, only: [], controller: 'tasks' do
+ post :rewrite
+ post :summarize
+ post :reply_suggestion
+ post :label_suggestion
+ post :follow_up
+ end
end
resource :saml_settings, only: [:show, :create, :update, :destroy]
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
@@ -189,6 +196,9 @@ Rails.application.routes.draw do
get :metrics
get :download
end
+ member do
+ patch :update if ChatwootApp.enterprise?
+ end
end
resources :applied_slas, only: [:index] do
collection do
diff --git a/db/migrate/20260114192518_add_internal_observations_to_csat_survey_responses.rb b/db/migrate/20260114192518_add_internal_observations_to_csat_survey_responses.rb
new file mode 100644
index 000000000..687064b90
--- /dev/null
+++ b/db/migrate/20260114192518_add_internal_observations_to_csat_survey_responses.rb
@@ -0,0 +1,5 @@
+class AddInternalObservationsToCsatSurveyResponses < ActiveRecord::Migration[7.1]
+ def change
+ add_column :csat_survey_responses, :csat_review_notes, :text
+ end
+end
diff --git a/db/migrate/20260114201315_add_observations_audit_to_csat_survey_responses.rb b/db/migrate/20260114201315_add_observations_audit_to_csat_survey_responses.rb
new file mode 100644
index 000000000..9bf1c5d1f
--- /dev/null
+++ b/db/migrate/20260114201315_add_observations_audit_to_csat_survey_responses.rb
@@ -0,0 +1,6 @@
+class AddObservationsAuditToCsatSurveyResponses < ActiveRecord::Migration[7.1]
+ def change
+ add_column :csat_survey_responses, :review_notes_updated_at, :datetime
+ add_reference :csat_survey_responses, :review_notes_updated_by, index: true
+ end
+end
diff --git a/db/migrate/20260120121402_enable_captain_tasks_for_existing_accounts.rb b/db/migrate/20260120121402_enable_captain_tasks_for_existing_accounts.rb
new file mode 100644
index 000000000..5305559c8
--- /dev/null
+++ b/db/migrate/20260120121402_enable_captain_tasks_for_existing_accounts.rb
@@ -0,0 +1,12 @@
+# Enable captain_tasks for existing accounts.
+# Unlike 20250416182131_flip_chatwoot_v4_default_feature_flag_installation_config.rb,
+# we don't need to update ACCOUNT_LEVEL_FEATURE_DEFAULTS or clear GlobalConfig cache
+# because captain_tasks already has `enabled: true` in features.yml - ConfigLoader
+# handles the defaults on deploy automatically.
+class EnableCaptainTasksForExistingAccounts < ActiveRecord::Migration[7.0]
+ def up
+ Account.find_in_batches(batch_size: 100) do |accounts|
+ accounts.each { |account| account.enable_features!('captain_tasks') }
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index bd4f0f968..148e7769c 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.1].define(version: 2026_01_12_092041) do
+ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -733,11 +733,15 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_12_092041) do
t.bigint "assigned_agent_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.text "csat_review_notes"
+ t.datetime "review_notes_updated_at"
+ t.bigint "review_notes_updated_by_id"
t.index ["account_id"], name: "index_csat_survey_responses_on_account_id"
t.index ["assigned_agent_id"], name: "index_csat_survey_responses_on_assigned_agent_id"
t.index ["contact_id"], name: "index_csat_survey_responses_on_contact_id"
t.index ["conversation_id"], name: "index_csat_survey_responses_on_conversation_id"
t.index ["message_id"], name: "index_csat_survey_responses_on_message_id", unique: true
+ t.index ["review_notes_updated_by_id"], name: "index_csat_survey_responses_on_review_notes_updated_by_id"
end
create_table "custom_attribute_definitions", force: :cascade do |t|
diff --git a/docker/Dockerfile b/docker/Dockerfile
index ea15f0c85..645a61a55 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -2,7 +2,7 @@
FROM node:24-alpine as node
FROM ruby:3.4.4-alpine3.21 AS pre-builder
-ARG NODE_VERSION="24.12.0"
+ARG NODE_VERSION="24.13.0"
ARG PNPM_VERSION="10.2.0"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
@@ -11,7 +11,7 @@ ENV PNPM_VERSION=${PNPM_VERSION}
# For development docker-compose file overrides ARGS
ARG BUNDLE_WITHOUT="development:test"
ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
-ENV BUNDLER_VERSION=2.5.11
+ENV BUNDLER_VERSION=2.5.16
ARG RAILS_SERVE_STATIC_FILES=true
ENV RAILS_SERVE_STATIC_FILES ${RAILS_SERVE_STATIC_FILES}
@@ -35,7 +35,7 @@ RUN apk update && apk add --no-cache \
curl \
xz \
&& mkdir -p /var/app \
- && gem install bundler
+ && gem install bundler -v "$BUNDLER_VERSION"
COPY --from=node /usr/local/bin/node /usr/local/bin/
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
@@ -98,14 +98,14 @@ RUN rm -rf /gems/ruby/3.4.0/cache/*.gem \
# final build stage
FROM ruby:3.4.4-alpine3.21
-ARG NODE_VERSION="24.12.0"
+ARG NODE_VERSION="24.13.0"
ARG PNPM_VERSION="10.2.0"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
ARG BUNDLE_WITHOUT="development:test"
ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
-ENV BUNDLER_VERSION=2.5.11
+ENV BUNDLER_VERSION=2.5.16
ARG EXECJS_RUNTIME="Disabled"
ENV EXECJS_RUNTIME ${EXECJS_RUNTIME}
@@ -128,7 +128,7 @@ RUN apk update && apk add --no-cache \
imagemagick \
git \
vips \
- && gem install bundler
+ && gem install bundler -v "$BUNDLER_VERSION"
COPY --from=node /usr/local/bin/node /usr/local/bin/
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb
new file mode 100644
index 000000000..d7208d678
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb
@@ -0,0 +1,71 @@
+class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseController
+ before_action :check_authorization
+
+ def rewrite
+ result = Captain::RewriteService.new(
+ account: Current.account,
+ content: params[:content],
+ operation: params[:operation],
+ conversation_display_id: params[:conversation_display_id]
+ ).perform
+
+ render_result(result)
+ end
+
+ def summarize
+ result = Captain::SummaryService.new(
+ account: Current.account,
+ conversation_display_id: params[:conversation_display_id]
+ ).perform
+
+ render_result(result)
+ end
+
+ def reply_suggestion
+ result = Captain::ReplySuggestionService.new(
+ account: Current.account,
+ conversation_display_id: params[:conversation_display_id],
+ user: Current.user
+ ).perform
+
+ render_result(result)
+ end
+
+ def label_suggestion
+ result = Captain::LabelSuggestionService.new(
+ account: Current.account,
+ conversation_display_id: params[:conversation_display_id]
+ ).perform
+
+ render_result(result)
+ end
+
+ def follow_up
+ result = Captain::FollowUpService.new(
+ account: Current.account,
+ follow_up_context: params[:follow_up_context]&.to_unsafe_h,
+ user_message: params[:message],
+ conversation_display_id: params[:conversation_display_id]
+ ).perform
+
+ render_result(result)
+ end
+
+ private
+
+ def render_result(result)
+ if result.nil?
+ render json: { message: nil }
+ elsif result[:error]
+ render json: { error: result[:error] }, status: :unprocessable_entity
+ else
+ response_data = { message: result[:message] }
+ response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
+ render json: response_data
+ end
+ end
+
+ def check_authorization
+ authorize(:'captain/tasks')
+ end
+end
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/csat_survey_responses_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/csat_survey_responses_controller.rb
new file mode 100644
index 000000000..ca1ebcefb
--- /dev/null
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/csat_survey_responses_controller.rb
@@ -0,0 +1,12 @@
+module Enterprise::Api::V1::Accounts::CsatSurveyResponsesController
+ def update
+ @csat_survey_response = Current.account.csat_survey_responses.find(params[:id])
+ authorize @csat_survey_response
+
+ @csat_survey_response.update!(
+ csat_review_notes: params[:csat_review_notes],
+ review_notes_updated_by: Current.user,
+ review_notes_updated_at: Time.current
+ )
+ end
+end
diff --git a/enterprise/app/policies/enterprise/csat_survey_response_policy.rb b/enterprise/app/policies/enterprise/csat_survey_response_policy.rb
index 4b0f5816e..8f614f873 100644
--- a/enterprise/app/policies/enterprise/csat_survey_response_policy.rb
+++ b/enterprise/app/policies/enterprise/csat_survey_response_policy.rb
@@ -10,4 +10,8 @@ module Enterprise::CsatSurveyResponsePolicy
def download?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
+
+ def update?
+ @account_user.administrator? || @account_user.custom_role&.permissions&.include?('report_manage')
+ end
end
diff --git a/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb b/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb
index 9e824d1f5..d4acb9837 100644
--- a/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb
+++ b/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb
@@ -4,9 +4,9 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
end
description 'Search conversations based on parameters'
- param :status, type: :string, desc: 'Status of the conversation'
+ param :status, type: :string, desc: 'Status of the conversation (open, resolved, pending, snoozed). Leave empty to search all statuses.'
param :contact_id, type: :number, desc: 'Contact id'
- param :priority, type: :string, desc: 'Priority of conversation'
+ param :priority, type: :string, desc: 'Priority of conversation (low, medium, high, urgent). Leave empty to search all priorities.'
param :labels, type: :string, desc: 'Labels available'
def execute(status: nil, contact_id: nil, priority: nil, labels: nil)
@@ -19,7 +19,7 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
<<~RESPONSE
#{total_count > 100 ? "Found #{total_count} conversations (showing first 100)" : "Total number of conversations: #{total_count}"}
- #{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true) }.join("\n---\n")}
+ #{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true, include_private_messages: true) }.join("\n---\n")}
RESPONSE
end
@@ -34,12 +34,20 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
def get_conversations(status, contact_id, priority, labels)
conversations = permissible_conversations
conversations = conversations.where(contact_id: contact_id) if contact_id.present?
- conversations = conversations.where(status: status) if status.present?
- conversations = conversations.where(priority: priority) if priority.present?
+ conversations = conversations.where(status: status) if valid_status?(status)
+ conversations = conversations.where(priority: priority) if valid_priority?(priority)
conversations = conversations.tagged_with(labels, any: true) if labels.present?
conversations
end
+ def valid_status?(status)
+ status.present? && Conversation.statuses.key?(status)
+ end
+
+ def valid_priority?(priority)
+ priority.present? && Conversation.priorities.key?(priority)
+ end
+
def permissible_conversations
Conversations::PermissionFilterService.new(
@assistant.account.conversations,
diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
index eab6a81dc..10887a1d7 100644
--- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
+++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
@@ -22,7 +22,7 @@ class Enterprise::Billing::HandleStripeEventService
].freeze
# Additional features available starting with the Business plan
- BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
+ BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes].freeze
# Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
diff --git a/enterprise/config/premium_features.yml b/enterprise/config/premium_features.yml
index 366cc82fa..9465a6e11 100644
--- a/enterprise/config/premium_features.yml
+++ b/enterprise/config/premium_features.yml
@@ -3,5 +3,6 @@
- audit_logs
- response_bot
- sla
-- captain_integration
- custom_roles
+- captain_integration
+- csat_review_notes
diff --git a/enterprise/lib/enterprise/captain/base_task_service.rb b/enterprise/lib/enterprise/captain/base_task_service.rb
new file mode 100644
index 000000000..9845359f5
--- /dev/null
+++ b/enterprise/lib/enterprise/captain/base_task_service.rb
@@ -0,0 +1,32 @@
+module Enterprise::Captain::BaseTaskService
+ def perform
+ return { error: I18n.t('captain.copilot_limit'), error_code: 429 } unless responses_available?
+
+ unless captain_tasks_enabled?
+ return { error: I18n.t('captain.upgrade') } if ChatwootApp.chatwoot_cloud?
+
+ return { error: I18n.t('captain.disabled') }
+ end
+
+ result = super
+ increment_usage if successful_result?(result)
+ result
+ end
+
+ private
+
+ def responses_available?
+ return true unless ChatwootApp.chatwoot_cloud?
+
+ account.usage_limits[:captain][:responses][:current_available].positive?
+ end
+
+ def successful_result?(result)
+ result.is_a?(Hash) && result[:message].present? && !result[:error]
+ end
+
+ def increment_usage
+ Rails.logger.info("[CAPTAIN][#{self.class.name}] Incrementing response usage for account #{account.id}")
+ account.increment_response_usage
+ end
+end
diff --git a/enterprise/lib/enterprise/integrations/openai_processor_service.rb b/enterprise/lib/enterprise/integrations/openai_processor_service.rb
deleted file mode 100644
index 5a98ad4c4..000000000
--- a/enterprise/lib/enterprise/integrations/openai_processor_service.rb
+++ /dev/null
@@ -1,82 +0,0 @@
-module Enterprise::Integrations::OpenaiProcessorService
- ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion label_suggestion fix_spelling_grammar shorten expand
- make_friendly make_formal simplify].freeze
- CACHEABLE_EVENTS = %w[label_suggestion].freeze
-
- def label_suggestion_message
- payload = label_suggestion_body
- return nil if payload.blank?
-
- response = make_api_call(label_suggestion_body)
-
- return response if response[:error].present?
-
- # LLMs are not deterministic, so this is bandaid solution
- # To what you ask? Sometimes, the response includes
- # "Labels:" in it's response in some format. This is a hacky way to remove it
- # TODO: Fix with with a better prompt
- { message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' }
- end
-
- private
-
- def labels_with_messages
- return nil unless valid_conversation?(conversation)
-
- labels = hook.account.labels.pluck(:title).join(', ')
- character_count = labels.length
-
- messages = init_messages_body(false)
- add_messages_until_token_limit(conversation, messages, false, character_count)
-
- return nil if messages.blank? || labels.blank?
-
- "Messages:\n#{messages}\nLabels:\n#{labels}"
- end
-
- def valid_conversation?(conversation)
- return false if conversation.nil?
- return false if conversation.messages.incoming.count < 3
-
- # Think Mark think, at this point the conversation is beyond saving
- return false if conversation.messages.count > 100
-
- # if there are more than 20 messages, only trigger this if the last message is from the client
- return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
-
- true
- end
-
- def summarize_body
- {
- model: self.class::GPT_MODEL,
- messages: [
- { role: 'system',
- content: prompt_from_file('summary', enterprise: true) },
- { role: 'user', content: conversation_messages }
- ]
- }.to_json
- end
-
- def label_suggestion_body
- return unless label_suggestions_enabled?
-
- content = labels_with_messages
- return value_from_cache if content.blank?
-
- {
- model: self.class::GPT_MODEL,
- messages: [
- {
- role: 'system',
- content: prompt_from_file('label_suggestion', enterprise: true)
- },
- { role: 'user', content: content }
- ]
- }.to_json
- end
-
- def label_suggestions_enabled?
- hook.settings['label_suggestion'].present?
- end
-end
diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb
new file mode 100644
index 000000000..b0cf7d240
--- /dev/null
+++ b/lib/captain/base_task_service.rb
@@ -0,0 +1,181 @@
+class Captain::BaseTaskService
+ include Integrations::LlmInstrumentation
+
+ # gpt-4o-mini supports 128,000 tokens
+ # 1 token is approx 4 characters
+ # sticking with 120000 to be safe
+ # 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
+ TOKEN_LIMIT = 400_000
+ GPT_MODEL = Llm::Config::DEFAULT_MODEL
+
+ # Prepend enterprise module to subclasses when they're defined.
+ # This ensures the enterprise perform wrapper is applied even when
+ # subclasses define their own perform method, since prepend puts
+ # the module before the class in the ancestor chain.
+ def self.inherited(subclass)
+ super
+ subclass.prepend_mod_with('Captain::BaseTaskService')
+ end
+
+ pattr_initialize [:account!, { conversation_display_id: nil }]
+
+ private
+
+ def event_name
+ raise NotImplementedError, "#{self.class} must implement #event_name"
+ end
+
+ def conversation
+ @conversation ||= account.conversations.find_by(display_id: conversation_display_id)
+ end
+
+ def api_base
+ endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
+ endpoint = endpoint.chomp('/')
+ "#{endpoint}/v1"
+ end
+
+ def make_api_call(model:, messages:)
+ # Community edition prerequisite checks
+ # Enterprise module handles these with more specific error messages (cloud vs self-hosted)
+ return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
+ return { error: I18n.t('captain.api_key_missing'), error_code: 401 } unless api_key_configured?
+
+ instrumentation_params = build_instrumentation_params(model, messages)
+
+ response = instrument_llm_call(instrumentation_params) do
+ execute_ruby_llm_request(model: model, messages: messages)
+ end
+
+ # Build follow-up context for client-side refinement, when applicable
+ if build_follow_up_context? && response[:message].present?
+ response.merge(follow_up_context: build_follow_up_context(messages, response))
+ else
+ response
+ end
+ end
+
+ def execute_ruby_llm_request(model:, messages:)
+ Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
+ chat = context.chat(model: model)
+ system_msg = messages.find { |m| m[:role] == 'system' }
+ chat.with_instructions(system_msg[:content]) if system_msg
+
+ conversation_messages = messages.reject { |m| m[:role] == 'system' }
+ return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
+
+ add_messages_if_needed(chat, conversation_messages)
+ response = chat.ask(conversation_messages.last[:content])
+ build_ruby_llm_response(response, messages)
+ end
+ rescue StandardError => e
+ ChatwootExceptionTracker.new(e, account: account).capture_exception
+ { error: e.message, request_messages: messages }
+ end
+
+ def add_messages_if_needed(chat, conversation_messages)
+ return if conversation_messages.length == 1
+
+ conversation_messages[0...-1].each do |msg|
+ chat.add_message(role: msg[:role].to_sym, content: msg[:content])
+ end
+ end
+
+ def build_ruby_llm_response(response, messages)
+ {
+ message: response.content,
+ usage: {
+ 'prompt_tokens' => response.input_tokens,
+ 'completion_tokens' => response.output_tokens,
+ 'total_tokens' => (response.input_tokens || 0) + (response.output_tokens || 0)
+ },
+ request_messages: messages
+ }
+ end
+
+ def build_instrumentation_params(model, messages)
+ {
+ span_name: "llm.#{event_name}",
+ account_id: account.id,
+ conversation_id: conversation&.display_id,
+ feature_name: event_name,
+ model: model,
+ messages: messages,
+ temperature: nil,
+ metadata: instrumentation_metadata
+ }
+ end
+
+ def instrumentation_metadata
+ {
+ channel_type: conversation&.inbox&.channel_type
+ }.compact
+ end
+
+ def conversation_messages(start_from: 0)
+ messages = []
+ character_count = start_from
+
+ conversation.messages
+ .where(message_type: [:incoming, :outgoing])
+ .where(private: false)
+ .reorder('id desc')
+ .each do |message|
+ content = message.content_for_llm
+ break unless content.present? && character_count + content.length <= TOKEN_LIMIT
+
+ messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content })
+ character_count += content.length
+ end
+
+ messages
+ end
+
+ def captain_tasks_enabled?
+ account.feature_enabled?('captain_tasks')
+ end
+
+ def api_key_configured?
+ api_key.present?
+ end
+
+ def api_key
+ @api_key ||= openai_hook&.settings&.dig('api_key') || system_api_key
+ end
+
+ def openai_hook
+ @openai_hook ||= account.hooks.find_by(app_id: 'openai', status: 'enabled')
+ end
+
+ def system_api_key
+ @system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
+ end
+
+ def prompt_from_file(file_name)
+ Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read
+ end
+
+ # Follow-up context for client-side refinement
+ def build_follow_up_context?
+ # FollowUpService should return its own updated context
+ !is_a?(Captain::FollowUpService)
+ end
+
+ def build_follow_up_context(messages, response)
+ {
+ event_name: event_name,
+ original_context: extract_original_context(messages),
+ last_response: response[:message],
+ conversation_history: [],
+ channel_type: conversation&.inbox&.channel_type
+ }
+ end
+
+ def extract_original_context(messages)
+ # Get the most recent user message for follow-up context
+ user_msg = messages.reverse.find { |m| m[:role] == 'user' }
+ user_msg ? user_msg[:content] : nil
+ end
+end
+
+Captain::BaseTaskService.prepend_mod_with('Captain::BaseTaskService')
diff --git a/lib/captain/follow_up_service.rb b/lib/captain/follow_up_service.rb
new file mode 100644
index 000000000..f02ba9408
--- /dev/null
+++ b/lib/captain/follow_up_service.rb
@@ -0,0 +1,106 @@
+class Captain::FollowUpService < Captain::BaseTaskService
+ pattr_initialize [:account!, :follow_up_context!, :user_message!, { conversation_display_id: nil }]
+
+ ALLOWED_EVENT_NAMES = %w[
+ professional
+ casual
+ friendly
+ confident
+ straightforward
+ fix_spelling_grammar
+ improve
+ summarize
+ reply_suggestion
+ label_suggestion
+ ].freeze
+
+ def perform
+ return { error: 'Follow-up context missing', error_code: 400 } unless valid_follow_up_context?
+
+ # Build context-aware system prompt
+ system_prompt = build_follow_up_system_prompt(follow_up_context)
+
+ # Build full message array (convert history from string keys to symbol keys)
+ history = follow_up_context['conversation_history'].to_a.map do |msg|
+ { role: msg['role'], content: msg['content'] }
+ end
+
+ messages = [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: follow_up_context['original_context'] },
+ { role: 'assistant', content: follow_up_context['last_response'] },
+ *history,
+ { role: 'user', content: user_message }
+ ]
+
+ response = make_api_call(model: GPT_MODEL, messages: messages)
+ return response if response[:error]
+
+ response.merge(follow_up_context: update_follow_up_context(user_message, response[:message]))
+ end
+
+ private
+
+ def build_follow_up_system_prompt(session_data)
+ action_context = describe_previous_action(session_data['event_name'])
+
+ <<~PROMPT
+ You just performed a #{action_context} action for a customer support agent.
+ Your job now is to help them refine the result based on their feedback.
+ Be concise and focused on their specific request.
+ Output only the reply, no preamble, tags, or explanation.
+ PROMPT
+ end
+
+ def describe_previous_action(event_name)
+ case event_name
+ when 'professional', 'casual', 'friendly', 'confident', 'straightforward'
+ "tone rewrite (#{event_name})"
+ when 'fix_spelling_grammar'
+ 'spelling and grammar correction'
+ when 'improve'
+ 'message improvement'
+ when 'summarize'
+ 'conversation summary'
+ when 'reply_suggestion'
+ 'reply suggestion'
+ when 'label_suggestion'
+ 'label suggestion'
+ else
+ event_name
+ end
+ end
+
+ def valid_follow_up_context?
+ return false unless follow_up_context.is_a?(Hash)
+ return false unless ALLOWED_EVENT_NAMES.include?(follow_up_context['event_name'])
+
+ required_keys = %w[event_name original_context last_response]
+ required_keys.all? { |key| follow_up_context[key].present? }
+ end
+
+ def update_follow_up_context(user_msg, assistant_msg)
+ updated_history = follow_up_context['conversation_history'].to_a + [
+ { 'role' => 'user', 'content' => user_msg },
+ { 'role' => 'assistant', 'content' => assistant_msg }
+ ]
+
+ {
+ 'event_name' => follow_up_context['event_name'],
+ 'original_context' => follow_up_context['original_context'],
+ 'last_response' => assistant_msg,
+ 'conversation_history' => updated_history,
+ 'channel_type' => follow_up_context['channel_type']
+ }
+ end
+
+ def instrumentation_metadata
+ {
+ channel_type: conversation&.inbox&.channel_type || follow_up_context['channel_type']
+ }.compact
+ end
+
+ def event_name
+ 'follow_up'
+ end
+end
diff --git a/lib/captain/label_suggestion_service.rb b/lib/captain/label_suggestion_service.rb
new file mode 100644
index 000000000..02f8bd89a
--- /dev/null
+++ b/lib/captain/label_suggestion_service.rb
@@ -0,0 +1,93 @@
+class Captain::LabelSuggestionService < Captain::BaseTaskService
+ pattr_initialize [:account!, :conversation_display_id!]
+
+ def perform
+ # Check cache first
+ cached_response = read_from_cache
+ return cached_response if cached_response.present?
+
+ # Build content
+ content = labels_with_messages
+ return nil if content.blank?
+
+ # Make API call
+ response = make_api_call(
+ model: GPT_MODEL, # TODO: Use separate model for label suggestion
+ messages: [
+ { role: 'system', content: prompt_from_file('label_suggestion') },
+ { role: 'user', content: content }
+ ]
+ )
+ return response if response[:error].present?
+
+ # Clean up response
+ result = { message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' }
+
+ # Cache successful result
+ write_to_cache(result)
+
+ result
+ end
+
+ private
+
+ def cache_key
+ return nil unless conversation
+
+ format(
+ ::Redis::Alfred::OPENAI_CONVERSATION_KEY,
+ event_name: 'label_suggestion',
+ conversation_id: conversation.id,
+ updated_at: conversation.last_activity_at.to_i
+ )
+ end
+
+ def read_from_cache
+ return nil unless cache_key
+
+ cached = Redis::Alfred.get(cache_key)
+ JSON.parse(cached, symbolize_names: true) if cached.present?
+ rescue JSON::ParserError
+ nil
+ end
+
+ def write_to_cache(response)
+ Redis::Alfred.setex(cache_key, response.to_json) if cache_key
+ end
+
+ def labels_with_messages
+ return nil unless valid_conversation?(conversation)
+
+ labels = account.labels.pluck(:title).join(', ')
+ messages = format_messages_as_string(start_from: labels.length)
+
+ return nil if messages.blank? || labels.blank?
+
+ "Messages:\n#{messages}\nLabels:\n#{labels}"
+ end
+
+ def format_messages_as_string(start_from: 0)
+ messages = conversation_messages(start_from: start_from)
+ messages.map do |msg|
+ sender_type = msg[:role] == 'user' ? 'Customer' : 'Agent'
+ "#{sender_type}: #{msg[:content]}\n"
+ end.join
+ end
+
+ def valid_conversation?(conversation)
+ return false if conversation.nil?
+ return false if conversation.messages.incoming.count < 3
+ return false if conversation.messages.count > 100
+ return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
+
+ true
+ end
+
+ def event_name
+ 'label_suggestion'
+ end
+
+ def build_follow_up_context?
+ false
+ end
+end
diff --git a/lib/captain/reply_suggestion_service.rb b/lib/captain/reply_suggestion_service.rb
new file mode 100644
index 000000000..8582258a8
--- /dev/null
+++ b/lib/captain/reply_suggestion_service.rb
@@ -0,0 +1,40 @@
+class Captain::ReplySuggestionService < Captain::BaseTaskService
+ pattr_initialize [:account!, :conversation_display_id!, :user!]
+
+ def perform
+ make_api_call(
+ model: GPT_MODEL,
+ messages: [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: formatted_conversation }
+ ]
+ )
+ end
+
+ private
+
+ def system_prompt
+ template = prompt_from_file('reply')
+ render_liquid_template(template, prompt_variables)
+ end
+
+ def prompt_variables
+ {
+ 'channel_type' => conversation.inbox.channel_type,
+ 'agent_name' => user.name,
+ 'agent_signature' => user.message_signature.presence
+ }
+ end
+
+ def render_liquid_template(template_content, variables = {})
+ Liquid::Template.parse(template_content).render(variables)
+ end
+
+ def formatted_conversation
+ LlmFormatter::ConversationLlmFormatter.new(conversation).format(token_limit: TOKEN_LIMIT)
+ end
+
+ def event_name
+ 'reply_suggestion'
+ end
+end
diff --git a/lib/captain/rewrite_service.rb b/lib/captain/rewrite_service.rb
new file mode 100644
index 000000000..3a217d3c6
--- /dev/null
+++ b/lib/captain/rewrite_service.rb
@@ -0,0 +1,59 @@
+class Captain::RewriteService < Captain::BaseTaskService
+ pattr_initialize [:account!, :content!, :operation!, { conversation_display_id: nil }]
+
+ TONE_OPERATIONS = %i[casual professional friendly confident straightforward].freeze
+ ALLOWED_OPERATIONS = (%i[fix_spelling_grammar improve] + TONE_OPERATIONS).freeze
+
+ def perform
+ operation_sym = operation.to_sym
+ raise ArgumentError, "Invalid operation: #{operation}" unless ALLOWED_OPERATIONS.include?(operation_sym)
+
+ send(operation_sym)
+ end
+
+ TONE_OPERATIONS.each do |tone|
+ define_method(tone) do
+ call_llm_with_prompt(tone_rewrite_prompt(tone.to_s))
+ end
+ end
+
+ private
+
+ def fix_spelling_grammar
+ call_llm_with_prompt(prompt_from_file('fix_spelling_grammar'))
+ end
+
+ def improve
+ template = prompt_from_file('improve')
+
+ system_prompt = render_liquid_template(template, {
+ 'conversation_context' => conversation.to_llm_text(include_contact_details: true),
+ 'draft_message' => content
+ })
+
+ call_llm_with_prompt(system_prompt, content)
+ end
+
+ def call_llm_with_prompt(system_content, user_content = content)
+ make_api_call(
+ model: GPT_MODEL,
+ messages: [
+ { role: 'system', content: system_content },
+ { role: 'user', content: user_content }
+ ]
+ )
+ end
+
+ def render_liquid_template(template_content, variables = {})
+ Liquid::Template.parse(template_content).render(variables)
+ end
+
+ def tone_rewrite_prompt(tone)
+ template = prompt_from_file('tone_rewrite')
+ render_liquid_template(template, 'tone' => tone)
+ end
+
+ def event_name
+ operation
+ end
+end
diff --git a/lib/captain/summary_service.rb b/lib/captain/summary_service.rb
new file mode 100644
index 000000000..16ee57b51
--- /dev/null
+++ b/lib/captain/summary_service.rb
@@ -0,0 +1,19 @@
+class Captain::SummaryService < Captain::BaseTaskService
+ pattr_initialize [:account!, :conversation_display_id!]
+
+ def perform
+ make_api_call(
+ model: GPT_MODEL,
+ messages: [
+ { role: 'system', content: prompt_from_file('summary') },
+ { role: 'user', content: conversation.to_llm_text(include_contact_details: false) }
+ ]
+ )
+ end
+
+ private
+
+ def event_name
+ 'summarize'
+ end
+end
diff --git a/lib/integrations/llm_base_service.rb b/lib/integrations/llm_base_service.rb
index ca9459fc8..397888b83 100644
--- a/lib/integrations/llm_base_service.rb
+++ b/lib/integrations/llm_base_service.rb
@@ -7,7 +7,8 @@ class Integrations::LlmBaseService
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
TOKEN_LIMIT = 400_000
GPT_MODEL = Llm::Config::DEFAULT_MODEL
- ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze
+ ALLOWED_EVENT_NAMES = %w[summarize reply_suggestion fix_spelling_grammar casual professional friendly confident
+ straightforward improve].freeze
CACHEABLE_EVENTS = %w[].freeze
pattr_initialize [:hook!, :event!]
diff --git a/lib/integrations/openai/openai_prompts/fix_spelling_grammar.liquid b/lib/integrations/openai/openai_prompts/fix_spelling_grammar.liquid
new file mode 100644
index 000000000..520487357
--- /dev/null
+++ b/lib/integrations/openai/openai_prompts/fix_spelling_grammar.liquid
@@ -0,0 +1,17 @@
+You are an AI writing assistant integrated into Chatwoot, an omnichannel customer support platform. Your task is to fix grammar and spelling in a customer support message while preserving the original meaning, intent, and tone.
+
+You will receive a message and must return a corrected version with only grammar, spelling, and punctuation fixes applied.
+
+Important guidelines:
+- Preserve the original meaning, intent, and tone exactly
+- Do not rephrase, rewrite, or change wording beyond grammar, spelling, and punctuation
+- Do not add or remove any information
+- Do not simplify, shorten, or expand the message
+- Ensure the output remains appropriate for customer support
+
+Super Important:
+- If the message has some markdown formatting, keep the formatting as it is.
+- Block quotes (lines starting with >) contain quoted text from the customer's previous message. Preserve this quoted text exactly as written (do not modify the customer's words inside the block quote), but DO improve the agent's reply that follows the block quote.
+- Ensure the output is in the user's original language
+
+Output only the corrected message, with no preamble, tags, or explanation.
diff --git a/lib/integrations/openai/openai_prompts/improve.liquid b/lib/integrations/openai/openai_prompts/improve.liquid
new file mode 100644
index 000000000..7e3d35e82
--- /dev/null
+++ b/lib/integrations/openai/openai_prompts/improve.liquid
@@ -0,0 +1,43 @@
+You are a writing assistant for customer support agents. Your task is to improve a draft message by enhancing its language, clarity, and tone—not by adding new content.
+
+
+{{ conversation_context }}
+
+
+
+{{ draft_message }}
+
+
+## Your Task
+
+Rewrite the draft to be clearer, warmer, and more professional while preserving the agent's intent.
+
+## What "Improve" Means
+
+Improve the **quality** of the message, not the **quantity** of information:
+
+| DO | DON'T |
+|-----|--------|
+| Fix grammar, spelling, punctuation | Add new information or steps |
+| Improve sentence structure and flow | Expand scope beyond the draft |
+| Make tone warmer and more professional | Add offers ("I can also...", "Would you like...") |
+| Use contact's name naturally | Invent technical details, links, or examples |
+| Make vague phrases more natural | Turn a brief answer into a long one |
+
+## Using the Context
+
+Use the conversation context to:
+- Understand what's being discussed (so improvements make sense)
+- Gauge appropriate tone (formal/casual, frustrated customer, etc.)
+- Personalize with the contact's name when natural
+
+Do NOT use the context to fill in gaps or add information the agent didn't include.
+
+## Output Rules
+
+- Keep the improved message at a similar length to the draft (brief stays brief)
+- Preserve any markdown formatting
+- Block quotes (lines starting with `>`) contain quoted customer text—keep this unchanged, only improve the agent's reply
+- Output in the same language as the draft
+- Output only the improved message, no commentary
+
diff --git a/enterprise/lib/enterprise/integrations/openai_prompts/label_suggestion.txt b/lib/integrations/openai/openai_prompts/label_suggestion.liquid
similarity index 88%
rename from enterprise/lib/enterprise/integrations/openai_prompts/label_suggestion.txt
rename to lib/integrations/openai/openai_prompts/label_suggestion.liquid
index 6b0e436a4..7c76288f7 100644
--- a/enterprise/lib/enterprise/integrations/openai_prompts/label_suggestion.txt
+++ b/lib/integrations/openai/openai_prompts/label_suggestion.liquid
@@ -1 +1 @@
-Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you\'ve selected,in their original casing, and nothing else.
\ No newline at end of file
+Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you've selected,in their original casing, and nothing else.
diff --git a/lib/integrations/openai/openai_prompts/reply.liquid b/lib/integrations/openai/openai_prompts/reply.liquid
new file mode 100644
index 000000000..19db51a05
--- /dev/null
+++ b/lib/integrations/openai/openai_prompts/reply.liquid
@@ -0,0 +1,35 @@
+You are helping a customer support agent draft their next reply. The agent will send this message directly to the customer.
+
+You will receive a conversation with messages labeled by sender:
+- "User:" = customer messages
+- "Support Agent:" = human agent messages
+- "Bot:" = automated bot messages
+
+{% if channel_type == 'Channel::Email' %}
+This is an EMAIL conversation. Write a professional email reply that:
+- Uses appropriate email formatting (greeting, body, sign-off)
+- Is detailed and thorough where needed
+- Maintains a professional tone
+{% if agent_signature %}
+- End with the agent's signature exactly as provided below:
+
+{{ agent_signature }}
+{% else %}
+- End with a professional sign-off using the agent's name: {{ agent_name }}
+{% endif %}
+{% else %}
+This is a CHAT conversation. Write a brief, conversational reply that:
+- Is short and easy to read
+- Gets to the point quickly
+- Does not include formal greetings or sign-offs
+{% endif %}
+
+General guidelines:
+- Address the customer's most recent message directly
+- If a support agent has spoken before, match their writing style
+- If only bot messages exist, write a natural first message
+- Move the conversation forward
+- Do not invent product details, policies, or links that weren't mentioned
+- Reply in the customer's language
+
+Output only the reply.
diff --git a/lib/integrations/openai/openai_prompts/reply.txt b/lib/integrations/openai/openai_prompts/reply.txt
deleted file mode 100644
index 77ff0a72e..000000000
--- a/lib/integrations/openai/openai_prompts/reply.txt
+++ /dev/null
@@ -1 +0,0 @@
-Please suggest a reply to the following conversation between support agents and customer. Don't expose that you are an AI model, respond "Couldn't generate the reply" in cases where you can't answer. Reply in the user\'s language.
diff --git a/enterprise/lib/enterprise/integrations/openai_prompts/summary.txt b/lib/integrations/openai/openai_prompts/summary.liquid
similarity index 93%
rename from enterprise/lib/enterprise/integrations/openai_prompts/summary.txt
rename to lib/integrations/openai/openai_prompts/summary.liquid
index 5196f5b1b..4ec5ffd5b 100644
--- a/enterprise/lib/enterprise/integrations/openai_prompts/summary.txt
+++ b/lib/integrations/openai/openai_prompts/summary.liquid
@@ -1,13 +1,13 @@
-As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
+As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
Make sure you strongly adhere to the following rules when generating the summary
-1. Be brief and concise. The shorter the summary the better.
-2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
+1. Be brief and concise. The shorter the summary the better.
+2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
3. Describe the customer intent in around 50 words.
4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc.
5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell.
-6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
+6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included.
8. The 'Action Items' should be brief and concise
9. Mark important words or parts of sentences as bold.
@@ -25,4 +25,4 @@ Reply in the user's language, as a markdown of the following format.
**Action Items**
-**Follow-up Items**
\ No newline at end of file
+**Follow-up Items**
diff --git a/lib/integrations/openai/openai_prompts/summary.txt b/lib/integrations/openai/openai_prompts/summary.txt
deleted file mode 100644
index 3f1d93227..000000000
--- a/lib/integrations/openai/openai_prompts/summary.txt
+++ /dev/null
@@ -1 +0,0 @@
-Please summarize the key points from the following conversation between support agents and customer as bullet points for the next support agent looking into the conversation. Reply in the user's language.
\ No newline at end of file
diff --git a/lib/integrations/openai/openai_prompts/tone_rewrite.liquid b/lib/integrations/openai/openai_prompts/tone_rewrite.liquid
new file mode 100644
index 000000000..c140a93df
--- /dev/null
+++ b/lib/integrations/openai/openai_prompts/tone_rewrite.liquid
@@ -0,0 +1,35 @@
+You are an AI writing assistant integrated into Chatwoot, an omnichannel customer support platform. Your task is to rewrite customer support message to match a specific tone while preserving the original meaning and intent.
+
+Here is the tone to apply to the message you will receive:
+
+{% case tone %}
+{% when 'friendly' %}
+Warm, approachable, and personable. Use conversational language, positive words, and show empathy. May include phrases like "Happy to help!" or "I'd be glad to..."
+{% when 'confident' %}
+Assertive and assured. Use definitive language, avoid hedging words like "maybe" or "I think". Be direct and authoritative while remaining helpful.
+{% when 'straightforward' %}
+Clear, direct, and to-the-point. Remove unnecessary words, get straight to the information or solution. No fluff or extra pleasantries.
+{% when 'casual' %}
+Relaxed and informal. Use contractions, simpler words, and a conversational style. Friendly but less formal than professional tone.
+{% when 'professional' %}
+Formal, polished, and business-appropriate. Use complete sentences, proper grammar, and maintain respectful distance. Avoid slang or overly casual language.
+{% else %}
+Warm, approachable, and personable. Use conversational language, positive words, and show empathy. May include phrases like "Happy to help!" or "I'd be glad to..."
+{% endcase %}
+
+
+Your task is to rewrite the message according to the specified tone instructions.
+
+Important guidelines:
+- Preserve the core meaning and all important information from the original message
+- Keep the rewritten message concise and appropriate for customer support
+- Maintain helpfulness and respect regardless of tone
+- Do not add information that wasn't in the original message
+- Do not remove critical details or instructions
+
+Super Important:
+- If the message has some markdown formatting, keep the formatting as it is.
+- Block quotes (lines starting with >) contain quoted text from the customer's previous message. Preserve this quoted text exactly as written (do not modify the customer's words inside the block quote), but DO improve the agent's reply that follows the block quote.
+- Ensure the output is in the user's original language
+
+Output only the rewritten message without any preamble, tags or explanation.
diff --git a/lib/integrations/openai/processor_service.rb b/lib/integrations/openai/processor_service.rb
deleted file mode 100644
index 2f0180701..000000000
--- a/lib/integrations/openai/processor_service.rb
+++ /dev/null
@@ -1,138 +0,0 @@
-class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
- AGENT_INSTRUCTION = 'You are a helpful support agent.'.freeze
- LANGUAGE_INSTRUCTION = 'Ensure that the reply should be in user language.'.freeze
- def reply_suggestion_message
- make_api_call(reply_suggestion_body)
- end
-
- def summarize_message
- make_api_call(summarize_body)
- end
-
- def rephrase_message
- make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please rephrase the following response. " \
- "#{LANGUAGE_INSTRUCTION}"))
- end
-
- def fix_spelling_grammar_message
- make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please fix the spelling and grammar of the following response. " \
- "#{LANGUAGE_INSTRUCTION}"))
- end
-
- def shorten_message
- make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please shorten the following response. " \
- "#{LANGUAGE_INSTRUCTION}"))
- end
-
- def expand_message
- make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please expand the following response. " \
- "#{LANGUAGE_INSTRUCTION}"))
- end
-
- def make_friendly_message
- make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more friendly. " \
- "#{LANGUAGE_INSTRUCTION}"))
- end
-
- def make_formal_message
- make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more formal. " \
- "#{LANGUAGE_INSTRUCTION}"))
- end
-
- def simplify_message
- make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please simplify the following response. " \
- "#{LANGUAGE_INSTRUCTION}"))
- end
-
- private
-
- def prompt_from_file(file_name, enterprise: false)
- path = enterprise ? 'enterprise/lib/enterprise/integrations/openai_prompts' : 'lib/integrations/openai/openai_prompts'
- Rails.root.join(path, "#{file_name}.txt").read
- end
-
- def build_api_call_body(system_content, user_content = event['data']['content'])
- {
- model: GPT_MODEL,
- messages: [
- { role: 'system', content: system_content },
- { role: 'user', content: user_content }
- ]
- }.to_json
- end
-
- def conversation_messages(in_array_format: false)
- messages = init_messages_body(in_array_format)
-
- add_messages_until_token_limit(conversation, messages, in_array_format)
- end
-
- def add_messages_until_token_limit(conversation, messages, in_array_format, start_from = 0)
- character_count = start_from
- conversation.messages.where(message_type: [:incoming, :outgoing]).where(private: false).reorder('id desc').each do |message|
- character_count, message_added = add_message_if_within_limit(character_count, message, messages, in_array_format)
- break unless message_added
- end
- messages
- end
-
- def add_message_if_within_limit(character_count, message, messages, in_array_format)
- content = message.content_for_llm
- if valid_message?(content, character_count)
- add_message_to_list(message, messages, in_array_format, content)
- character_count += content.length
- [character_count, true]
- else
- [character_count, false]
- end
- end
-
- def valid_message?(content, character_count)
- content.present? && character_count + content.length <= TOKEN_LIMIT
- end
-
- def add_message_to_list(message, messages, in_array_format, content)
- formatted_message = format_message(message, in_array_format, content)
- messages.prepend(formatted_message)
- end
-
- def init_messages_body(in_array_format)
- in_array_format ? [] : ''
- end
-
- def format_message(message, in_array_format, content)
- in_array_format ? format_message_in_array(message, content) : format_message_in_string(message, content)
- end
-
- def format_message_in_array(message, content)
- { role: (message.incoming? ? 'user' : 'assistant'), content: content }
- end
-
- def format_message_in_string(message, content)
- sender_type = message.incoming? ? 'Customer' : 'Agent'
- "#{sender_type} #{message.sender&.name} : #{content}\n"
- end
-
- def summarize_body
- {
- model: GPT_MODEL,
- messages: [
- { role: 'system',
- content: prompt_from_file('summary', enterprise: false) },
- { role: 'user', content: conversation_messages }
- ]
- }.to_json
- end
-
- def reply_suggestion_body
- {
- model: GPT_MODEL,
- messages: [
- { role: 'system',
- content: prompt_from_file('reply', enterprise: false) }
- ].concat(conversation_messages(in_array_format: true))
- }.to_json
- end
-end
-
-Integrations::Openai::ProcessorService.prepend_mod_with('Integrations::OpenaiProcessorService')
diff --git a/lib/llm/config.rb b/lib/llm/config.rb
index 94836d746..48de51022 100644
--- a/lib/llm/config.rb
+++ b/lib/llm/config.rb
@@ -1,7 +1,8 @@
require 'ruby_llm'
module Llm::Config
- DEFAULT_MODEL = 'gpt-4o-mini'.freeze
+ DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
+
class << self
def initialized?
@initialized ||= false
diff --git a/lib/tasks/download_report.rake b/lib/tasks/download_report.rake
new file mode 100644
index 000000000..c68418432
--- /dev/null
+++ b/lib/tasks/download_report.rake
@@ -0,0 +1,183 @@
+# Download Report Rake Tasks
+#
+# Usage:
+# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:agent
+# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:inbox
+# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:label
+#
+# The task will prompt for:
+# - Account ID
+# - Start Date (YYYY-MM-DD)
+# - End Date (YYYY-MM-DD)
+# - Timezone Offset (e.g., 0, 5.5, -5)
+# - Business Hours (y/n) - whether to use business hours for time metrics
+#
+# Output:
___.csv
+
+require 'csv'
+
+# rubocop:disable Metrics/CyclomaticComplexity
+# rubocop:disable Metrics/AbcSize
+# rubocop:disable Metrics/MethodLength
+# rubocop:disable Metrics/ModuleLength
+module DownloadReportTasks
+ def self.prompt(message)
+ print "#{message}: "
+ $stdin.gets.chomp
+ end
+
+ def self.collect_params
+ account_id = prompt('Enter Account ID')
+ abort 'Error: Account ID is required' if account_id.blank?
+
+ account = Account.find_by(id: account_id)
+ abort "Error: Account with ID '#{account_id}' not found" unless account
+
+ start_date = prompt('Enter Start Date (YYYY-MM-DD)')
+ abort 'Error: Start date is required' if start_date.blank?
+
+ end_date = prompt('Enter End Date (YYYY-MM-DD)')
+ abort 'Error: End date is required' if end_date.blank?
+
+ timezone_offset = prompt('Enter Timezone Offset (e.g., 0, 5.5, -5)')
+ timezone_offset = timezone_offset.blank? ? 0 : timezone_offset.to_f
+
+ business_hours = prompt('Use Business Hours? (y/n)')
+ business_hours = business_hours.downcase == 'y'
+
+ begin
+ tz = ActiveSupport::TimeZone[timezone_offset]
+ abort "Error: Invalid timezone offset '#{timezone_offset}'" unless tz
+
+ since = tz.parse("#{start_date} 00:00:00").to_i.to_s
+ until_date = tz.parse("#{end_date} 23:59:59").to_i.to_s
+ rescue StandardError => e
+ abort "Error parsing dates: #{e.message}"
+ end
+
+ {
+ account: account,
+ params: { since: since, until: until_date, timezone_offset: timezone_offset, business_hours: business_hours },
+ start_date: start_date,
+ end_date: end_date
+ }
+ end
+
+ def self.save_csv(filename, headers, rows)
+ CSV.open(filename, 'w') do |csv|
+ csv << headers
+ rows.each { |row| csv << row }
+ end
+ puts "Report saved to: #{filename}"
+ end
+
+ def self.format_time(seconds)
+ return '' if seconds.nil? || seconds.zero?
+
+ seconds.round(2)
+ end
+
+ def self.download_agent_report
+ data = collect_params
+ account = data[:account]
+
+ puts "\nGenerating agent report..."
+ builder = V2::Reports::AgentSummaryBuilder.new(account: account, params: data[:params])
+ report = builder.build
+
+ users = account.users.index_by(&:id)
+ headers = %w[id name email conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time]
+
+ rows = report.map do |row|
+ user = users[row[:id]]
+ [
+ row[:id],
+ user&.name || 'Unknown',
+ user&.email || 'Unknown',
+ row[:conversations_count],
+ row[:resolved_conversations_count],
+ format_time(row[:avg_resolution_time]),
+ format_time(row[:avg_first_response_time]),
+ format_time(row[:avg_reply_time])
+ ]
+ end
+
+ filename = "#{account.id}_agent_#{data[:start_date]}_#{data[:end_date]}.csv"
+ save_csv(filename, headers, rows)
+ end
+
+ def self.download_inbox_report
+ data = collect_params
+ account = data[:account]
+
+ puts "\nGenerating inbox report..."
+ builder = V2::Reports::InboxSummaryBuilder.new(account: account, params: data[:params])
+ report = builder.build
+
+ inboxes = account.inboxes.index_by(&:id)
+ headers = %w[id name conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time]
+
+ rows = report.map do |row|
+ inbox = inboxes[row[:id]]
+ [
+ row[:id],
+ inbox&.name || 'Unknown',
+ row[:conversations_count],
+ row[:resolved_conversations_count],
+ format_time(row[:avg_resolution_time]),
+ format_time(row[:avg_first_response_time]),
+ format_time(row[:avg_reply_time])
+ ]
+ end
+
+ filename = "#{account.id}_inbox_#{data[:start_date]}_#{data[:end_date]}.csv"
+ save_csv(filename, headers, rows)
+ end
+
+ def self.download_label_report
+ data = collect_params
+ account = data[:account]
+
+ puts "\nGenerating label report..."
+ builder = V2::Reports::LabelSummaryBuilder.new(account: account, params: data[:params])
+ report = builder.build
+
+ headers = %w[id name conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time]
+
+ rows = report.map do |row|
+ [
+ row[:id],
+ row[:name],
+ row[:conversations_count],
+ row[:resolved_conversations_count],
+ format_time(row[:avg_resolution_time]),
+ format_time(row[:avg_first_response_time]),
+ format_time(row[:avg_reply_time])
+ ]
+ end
+
+ filename = "#{account.id}_label_#{data[:start_date]}_#{data[:end_date]}.csv"
+ save_csv(filename, headers, rows)
+ end
+end
+# rubocop:enable Metrics/CyclomaticComplexity
+# rubocop:enable Metrics/AbcSize
+# rubocop:enable Metrics/MethodLength
+# rubocop:enable Metrics/ModuleLength
+
+namespace :download_report do
+ desc 'Download agent summary report as CSV'
+ task agent: :environment do
+ DownloadReportTasks.download_agent_report
+ end
+
+ desc 'Download inbox summary report as CSV'
+ task inbox: :environment do
+ DownloadReportTasks.download_inbox_report
+ end
+
+ desc 'Download label summary report as CSV'
+ task label: :environment do
+ DownloadReportTasks.download_label_report
+ end
+end
diff --git a/package.json b/package.json
index 68d7df574..04821480d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.9.2",
+ "version": "4.10.1",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -31,14 +31,16 @@
}
],
"dependencies": {
+ "@amplitude/analytics-browser": "^2.11.10",
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
- "@chatwoot/prosemirror-schema": "1.3.5",
+ "@chatwoot/prosemirror-schema": "1.3.6",
"@chatwoot/utils": "^0.0.51",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@highlightjs/vue-plugin": "^2.1.0",
+ "@iconify-json/fluent": "^1.2.32",
"@iconify-json/material-symbols": "^1.2.10",
"@lk77/vue3-color": "^3.0.6",
"@radix-ui/colors": "^3.0.0",
@@ -83,7 +85,6 @@
"mitt": "^3.0.1",
"opus-recorder": "^8.0.5",
"pinia": "^3.0.4",
- "@amplitude/analytics-browser": "^2.11.10",
"qrcode": "^1.5.4",
"semver": "7.6.3",
"snakecase-keys": "^8.0.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 27d0281a6..7a1b6f35f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,8 +23,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.3.5
- version: 1.3.5
+ specifier: 1.3.6
+ version: 1.3.6
'@chatwoot/utils':
specifier: ^0.0.51
version: 0.0.51
@@ -40,6 +40,9 @@ importers:
'@highlightjs/vue-plugin':
specifier: ^2.1.0
version: 2.1.0(highlight.js@11.10.0)(vue@3.5.12(typescript@5.6.2))
+ '@iconify-json/fluent':
+ specifier: ^1.2.32
+ version: 1.2.36
'@iconify-json/material-symbols':
specifier: ^1.2.10
version: 1.2.10
@@ -454,8 +457,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.3.5':
- resolution: {integrity: sha512-3Koj3jwO1qOxJG84D4FqPOJ6o8k6ehZi1zedO3vKRERATm2Cy1p+ET6FEvVYWUpoBvDwR6hNVScXrcNNVobhsA==}
+ '@chatwoot/prosemirror-schema@1.3.6':
+ resolution: {integrity: sha512-sHRtWqbtiow9mVF1ixim0eGUXfhGK5tuLOdF9Vf53aepjJ+ngEiNVkxQT6FohlEOd886ZsdQxMvmI92IDaUXAQ==}
'@chatwoot/utils@0.0.51':
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
@@ -961,6 +964,9 @@ packages:
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
deprecated: Use @eslint/object-schema instead
+ '@iconify-json/fluent@1.2.36':
+ resolution: {integrity: sha512-DhxwOu5Qiq09o2ehHeUK0I9lC01OeWFlxXP7pIslM0vbi8VuplrrJ7kMg21GJy87iOCevzxf6gTU7TLPGgSknw==}
+
'@iconify-json/logos@1.2.10':
resolution: {integrity: sha512-qxaXKJ6fu8jzTMPQdHtNxlfx6tBQ0jXRbHZIYy5Ilh8Lx9US9FsAdzZWUR8MXV8PnWTKGDFO4ZZee9VwerCyMA==}
@@ -4993,7 +4999,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.3.5':
+ '@chatwoot/prosemirror-schema@1.3.6':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
@@ -5513,6 +5519,10 @@ snapshots:
'@humanwhocodes/object-schema@2.0.3': {}
+ '@iconify-json/fluent@1.2.36':
+ dependencies:
+ '@iconify/types': 2.0.0
+
'@iconify-json/logos@1.2.10':
dependencies:
'@iconify/types': 2.0.0
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/csat_survey_responses_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/csat_survey_responses_controller_spec.rb
new file mode 100644
index 000000000..10ff87419
--- /dev/null
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/csat_survey_responses_controller_spec.rb
@@ -0,0 +1,85 @@
+require 'rails_helper'
+
+RSpec.describe 'Enterprise CSAT Survey Responses API', type: :request do
+ let(:account) { create(:account) }
+ let(:administrator) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let!(:csat_survey_response) { create(:csat_survey_response, account: account) }
+
+ describe 'PATCH /api/v1/accounts/{account.id}/csat_survey_responses/:id' do
+ let(:update_params) { { csat_review_notes: 'Customer was very satisfied with the resolution' } }
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
+ params: update_params,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated agent without permissions' do
+ it 'returns unauthorized' do
+ patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
+ headers: agent.create_new_auth_token,
+ params: update_params,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated administrator' do
+ it 'updates the csat survey response review notes' do
+ freeze_time do
+ patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
+ headers: administrator.create_new_auth_token,
+ params: update_params,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ csat_survey_response.reload
+ expect(csat_survey_response.csat_review_notes).to eq('Customer was very satisfied with the resolution')
+ expect(csat_survey_response.review_notes_updated_by).to eq(administrator)
+ expect(csat_survey_response.review_notes_updated_at).to eq(Time.current)
+ end
+ end
+ end
+
+ context 'when it is an agent with report_manage permission' do
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
+ let(:agent_with_role) { create(:user) }
+
+ before do
+ create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
+ end
+
+ it 'updates the csat survey response review notes' do
+ freeze_time do
+ patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
+ headers: agent_with_role.create_new_auth_token,
+ params: update_params,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ csat_survey_response.reload
+ expect(csat_survey_response.csat_review_notes).to eq('Customer was very satisfied with the resolution')
+ expect(csat_survey_response.review_notes_updated_by).to eq(agent_with_role)
+ expect(csat_survey_response.review_notes_updated_at).to eq(Time.current)
+ end
+ end
+ end
+
+ context 'when csat survey response does not exist' do
+ it 'returns not found' do
+ patch "/api/v1/accounts/#{account.id}/csat_survey_responses/0",
+ headers: administrator.create_new_auth_token,
+ params: update_params,
+ as: :json
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/lib/captain/base_task_service_spec.rb b/spec/enterprise/lib/captain/base_task_service_spec.rb
new file mode 100644
index 000000000..a018a7c84
--- /dev/null
+++ b/spec/enterprise/lib/captain/base_task_service_spec.rb
@@ -0,0 +1,169 @@
+require 'rails_helper'
+
+RSpec.describe Captain::BaseTaskService, type: :model do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:perform_result) { { message: 'Test response' } }
+
+ # Create a concrete test service class with enterprise module prepended
+ let(:test_service_class) do
+ result = perform_result
+ klass = Class.new(described_class) do
+ define_method(:perform) { result }
+
+ def event_name
+ 'test_event'
+ end
+ end
+ # Manually prepend enterprise module to test class
+ klass.prepend(Enterprise::Captain::BaseTaskService)
+ klass
+ end
+
+ let(:service) { test_service_class.new(account: account, conversation_display_id: conversation.display_id) }
+
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ end
+
+ describe '#perform with enterprise usage tracking' do
+ # Ensure captain is enabled by default for tests unless explicitly testing disabled state
+ before do
+ allow(account).to receive(:feature_enabled?).and_call_original
+ allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ end
+
+ context 'when usage limit is exceeded' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ allow(account).to receive(:usage_limits).and_return({
+ captain: { responses: { current_available: 0 } }
+ })
+ end
+
+ it 'returns usage limit exceeded error' do
+ result = service.perform
+ expect(result[:error]).to eq(I18n.t('captain.copilot_limit'))
+ expect(result[:error_code]).to eq(429)
+ end
+
+ it 'does not increment usage' do
+ expect(account).not_to receive(:increment_response_usage)
+ service.perform
+ end
+ end
+
+ it 'increments response usage on successful execution' do
+ expect(account).to receive(:increment_response_usage)
+ service.perform
+ end
+
+ context 'when result has an error' do
+ let(:perform_result) { { error: 'API Error' } }
+
+ it 'does not increment usage' do
+ expect(account).not_to receive(:increment_response_usage)
+ service.perform
+ end
+ end
+
+ context 'when result is nil' do
+ let(:perform_result) { nil }
+
+ it 'does not increment usage' do
+ expect(account).not_to receive(:increment_response_usage)
+ service.perform
+ end
+ end
+
+ context 'when result is empty hash' do
+ let(:perform_result) { {} }
+
+ it 'does not increment usage' do
+ expect(account).not_to receive(:increment_response_usage)
+ service.perform
+ end
+ end
+
+ context 'when result has blank message' do
+ let(:perform_result) { { message: '' } }
+
+ it 'does not increment usage' do
+ expect(account).not_to receive(:increment_response_usage)
+ service.perform
+ end
+ end
+
+ context 'when result has nil message' do
+ let(:perform_result) { { message: nil } }
+
+ it 'does not increment usage' do
+ expect(account).not_to receive(:increment_response_usage)
+ service.perform
+ end
+ end
+
+ it 'actually increments the usage counter in custom_attributes' do
+ expect do
+ service.perform
+ account.reload
+ end.to change { account.custom_attributes['captain_responses_usage'].to_i }.by(1)
+ end
+
+ context 'when captain is disabled' do
+ before do
+ allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
+ end
+
+ context 'when on Chatwoot Cloud' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ end
+
+ it 'returns upgrade error message' do
+ result = service.perform
+ expect(result[:error]).to eq(I18n.t('captain.upgrade'))
+ end
+
+ it 'does not increment usage' do
+ expect(account).not_to receive(:increment_response_usage)
+ service.perform
+ end
+ end
+
+ context 'when self-hosted' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
+ end
+
+ it 'returns disabled error message' do
+ result = service.perform
+ expect(result[:error]).to eq(I18n.t('captain.disabled'))
+ end
+
+ it 'does not increment usage' do
+ expect(account).not_to receive(:increment_response_usage)
+ service.perform
+ end
+ end
+ end
+
+ context 'when captain is enabled' do
+ before do
+ allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ end
+
+ it 'proceeds with the task' do
+ result = service.perform
+ expect(result[:message]).to eq('Test response')
+ expect(result[:error]).to be_nil
+ end
+
+ it 'increments usage' do
+ expect(account).to receive(:increment_response_usage)
+ service.perform
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/lib/integrations/openai/processor_service_spec.rb b/spec/enterprise/lib/integrations/openai/processor_service_spec.rb
deleted file mode 100644
index 88e75ea07..000000000
--- a/spec/enterprise/lib/integrations/openai/processor_service_spec.rb
+++ /dev/null
@@ -1,120 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe Integrations::Openai::ProcessorService do
- subject { described_class.new(hook: hook, event: event) }
-
- let(:account) { create(:account) }
- let(:hook) { create(:integrations_hook, :openai, account: account) }
-
- # Mock RubyLLM objects
- let(:mock_chat) { instance_double(RubyLLM::Chat) }
- let(:mock_context) { instance_double(RubyLLM::Context) }
- let(:mock_config) { OpenStruct.new }
- let(:mock_response) do
- instance_double(
- RubyLLM::Message,
- content: 'This is a reply from openai.',
- input_tokens: nil,
- output_tokens: nil
- )
- end
- let(:mock_empty_response) do
- instance_double(
- RubyLLM::Message,
- content: '',
- input_tokens: nil,
- output_tokens: nil
- )
- end
-
- let(:conversation) { create(:conversation, account: account) }
-
- before do
- allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context)
- allow(mock_context).to receive(:chat).and_return(mock_chat)
-
- allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
- allow(mock_chat).to receive(:add_message).and_return(mock_chat)
- allow(mock_chat).to receive(:ask).and_return(mock_response)
- end
-
- describe '#perform' do
- context 'when event name is label_suggestion with labels with < 3 messages' do
- let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
-
- it 'returns nil' do
- create(:label, account: account)
- create(:label, account: account)
-
- expect(subject.perform).to be_nil
- end
- end
-
- context 'when event name is label_suggestion with labels with >3 messages' do
- let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
-
- before do
- create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent')
- create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer')
- create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 2')
- create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 3')
- create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 4')
-
- create(:label, account: account)
- create(:label, account: account)
-
- hook.settings['label_suggestion'] = 'true'
- end
-
- it 'returns the label suggestions' do
- result = subject.perform
- expect(result).to eq({ message: 'This is a reply from openai.' })
- end
-
- it 'returns empty string if openai response is blank' do
- allow(mock_chat).to receive(:ask).and_return(mock_empty_response)
-
- result = subject.perform
- expect(result[:message]).to eq('')
- end
- end
-
- context 'when event name is label_suggestion with no labels' do
- let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
-
- it 'returns nil' do
- result = subject.perform
- expect(result).to be_nil
- end
- end
-
- context 'when event name is not one that can be processed' do
- let(:event) { { 'name' => 'unknown', 'data' => {} } }
-
- it 'returns nil' do
- expect(subject.perform).to be_nil
- end
- end
-
- context 'when hook is not enabled' do
- let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
-
- before do
- create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent')
- create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer')
- create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 2')
- create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 3')
- create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 4')
-
- create(:label, account: account)
- create(:label, account: account)
-
- hook.settings['label_suggestion'] = nil
- end
-
- it 'returns nil' do
- expect(subject.perform).to be_nil
- end
- end
- end
-end
diff --git a/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb
index a02d05404..0835b6125 100644
--- a/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb
+++ b/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb
@@ -119,5 +119,42 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
result = service.execute(status: 'snoozed')
expect(result).to eq('No conversations found')
end
+
+ context 'when invalid status is provided' do
+ it 'ignores invalid status and returns all conversations' do
+ result = service.execute(status: 'all')
+ expect(result).to include('Total number of conversations: 2')
+ expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
+ expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
+ end
+
+ it 'ignores random invalid status values' do
+ result = service.execute(status: 'invalid_status')
+ expect(result).to include('Total number of conversations: 2')
+ end
+ end
+
+ context 'when invalid priority is provided' do
+ it 'ignores invalid priority and returns all conversations' do
+ result = service.execute(priority: 'all')
+ expect(result).to include('Total number of conversations: 2')
+ expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
+ expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
+ end
+
+ it 'ignores random invalid priority values' do
+ result = service.execute(priority: 'invalid_priority')
+ expect(result).to include('Total number of conversations: 2')
+ end
+ end
+
+ context 'when combining valid and invalid parameters' do
+ it 'applies valid filters and ignores invalid ones' do
+ result = service.execute(status: 'all', contact_id: contact.id)
+ expect(result).to include('Total number of conversations: 1')
+ expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
+ expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
+ end
+ end
end
end
diff --git a/spec/factories/channel/channel_whatsapp.rb b/spec/factories/channel/channel_whatsapp.rb
index dae7eb04f..4282a374d 100644
--- a/spec/factories/channel/channel_whatsapp.rb
+++ b/spec/factories/channel/channel_whatsapp.rb
@@ -96,8 +96,16 @@ FactoryBot.define do
channel_whatsapp.define_singleton_method(:sync_templates) { nil } unless options.sync_templates
channel_whatsapp.define_singleton_method(:validate_provider_config) { nil } unless options.validate_provider_config
if channel_whatsapp.provider == 'whatsapp_cloud'
- channel_whatsapp.provider_config = channel_whatsapp.provider_config.merge({ 'api_key' => 'test_key', 'phone_number_id' => '123456789',
- 'business_account_id' => '123456789' })
+ # Add 'source' => 'embedded_signup' to skip after_commit :setup_webhooks callback in tests
+ # The callback is for manual setup flow; embedded signup handles webhook setup explicitly
+ # Only set source if not already provided (allows tests to override)
+ default_config = {
+ 'api_key' => 'test_key',
+ 'phone_number_id' => '123456789',
+ 'business_account_id' => '123456789'
+ }
+ default_config['source'] = 'embedded_signup' unless channel_whatsapp.provider_config.key?('source')
+ channel_whatsapp.provider_config = channel_whatsapp.provider_config.merge(default_config)
end
end
diff --git a/spec/jobs/conversations/resolution_job_spec.rb b/spec/jobs/conversations/resolution_job_spec.rb
index a80c39498..3bc443e20 100644
--- a/spec/jobs/conversations/resolution_job_spec.rb
+++ b/spec/jobs/conversations/resolution_job_spec.rb
@@ -48,6 +48,21 @@ RSpec.describe Conversations::ResolutionJob do
end
end
+ # When a contact is deleted, there's a brief window (~50-150ms) where contact_id becomes nil
+ # but conversations still exist. If ResolutionJob runs during this window, muted? can crash
+ # trying to call blocked? on nil. Fixes # (issue).
+ it 'skips orphan conversations without a contact' do
+ account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes
+ orphan_conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: nil)
+ orphan_conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
+ resolvable_conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: nil)
+
+ described_class.perform_now(account: account)
+
+ expect(orphan_conversation.reload.status).to eq('open')
+ expect(resolvable_conversation.reload.status).to eq('resolved')
+ end
+
it 'adds a label after resolution' do
account.update(auto_resolve_label: 'auto-resolved', auto_resolve_after: 14_400)
conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: 13.days.ago)
diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb
new file mode 100644
index 000000000..1ea666b5d
--- /dev/null
+++ b/spec/lib/captain/base_task_service_spec.rb
@@ -0,0 +1,325 @@
+require 'rails_helper'
+
+RSpec.describe Captain::BaseTaskService do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ # Create a concrete test service class since BaseTaskService is abstract
+ let(:test_service_class) do
+ Class.new(described_class) do
+ def perform
+ { message: 'Test response' }
+ end
+
+ def event_name
+ 'test_event'
+ end
+ end
+ end
+
+ let(:service) { test_service_class.new(account: account, conversation_display_id: conversation.display_id) }
+
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ # Stub captain enabled check to allow OSS specs to test base functionality
+ # without enterprise module interference
+ allow(account).to receive(:feature_enabled?).and_call_original
+ allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ end
+
+ describe '#perform' do
+ it 'returns the expected result' do
+ result = service.perform
+ expect(result).to eq({ message: 'Test response' })
+ end
+ end
+
+ describe '#event_name' do
+ it 'raises NotImplementedError for base class' do
+ base_service = described_class.new(account: account, conversation_display_id: conversation.display_id)
+ expect { base_service.send(:event_name) }.to raise_error(NotImplementedError, /must implement #event_name/)
+ end
+
+ it 'returns custom event name in subclass' do
+ expect(service.send(:event_name)).to eq('test_event')
+ end
+ end
+
+ describe '#conversation' do
+ it 'finds conversation by display_id' do
+ expect(service.send(:conversation)).to eq(conversation)
+ end
+
+ it 'memoizes the conversation' do
+ expect(account.conversations).to receive(:find_by).once.and_return(conversation)
+ service.send(:conversation)
+ service.send(:conversation)
+ end
+ end
+
+ describe '#conversation_messages' do
+ let(:message1) { create(:message, conversation: conversation, message_type: :incoming, content: 'Hello', created_at: 1.hour.ago) }
+ let(:message2) { create(:message, conversation: conversation, message_type: :outgoing, content: 'Hi there', created_at: 30.minutes.ago) }
+ let(:message3) { create(:message, conversation: conversation, message_type: :incoming, content: 'How are you?', created_at: 10.minutes.ago) }
+ let(:private_message) { create(:message, conversation: conversation, message_type: :incoming, content: 'Private', private: true) }
+
+ before do
+ message1
+ message2
+ message3
+ private_message
+ end
+
+ it 'returns messages in array format with role and content' do
+ messages = service.send(:conversation_messages)
+
+ expect(messages).to be_an(Array)
+ expect(messages.length).to eq(3)
+ expect(messages[0]).to eq({ role: 'user', content: 'Hello' })
+ expect(messages[1]).to eq({ role: 'assistant', content: 'Hi there' })
+ expect(messages[2]).to eq({ role: 'user', content: 'How are you?' })
+ end
+
+ it 'excludes private messages' do
+ messages = service.send(:conversation_messages)
+ contents = messages.pluck(:content)
+ expect(contents).not_to include('Private')
+ end
+
+ it 'respects token limit' do
+ # Create messages that collectively exceed token limit
+ # Message validation max is 150000, so create multiple large messages
+ 10.times do |i|
+ create(:message, conversation: conversation, message_type: :incoming,
+ content: 'a' * 100_000, created_at: i.minutes.ago)
+ end
+
+ messages = service.send(:conversation_messages)
+ total_length = messages.sum { |m| m[:content].length }
+ expect(total_length).to be <= Captain::BaseTaskService::TOKEN_LIMIT
+ end
+
+ it 'respects start_from offset for token counting' do
+ # With a start_from offset, fewer messages should fit
+ start_from = Captain::BaseTaskService::TOKEN_LIMIT - 100
+ messages = service.send(:conversation_messages, start_from: start_from)
+
+ total_length = messages.sum { |m| m[:content].length }
+ expect(total_length).to be <= 100
+ end
+ end
+
+ describe '#make_api_call' do
+ let(:model) { 'gpt-4' }
+ let(:messages) { [{ role: 'system', content: 'Test' }, { role: 'user', content: 'Hello' }] }
+ let(:mock_chat) { instance_double(RubyLLM::Chat) }
+ let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
+ let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) }
+
+ before do
+ allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
+ allow(mock_chat).to receive(:with_instructions)
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
+ end
+
+ context 'when captain_tasks is disabled' do
+ before do
+ allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
+ end
+
+ it 'returns disabled error' do
+ result = service.send(:make_api_call, model: model, messages: messages)
+
+ expect(result[:error]).to eq(I18n.t('captain.disabled'))
+ expect(result[:error_code]).to eq(403)
+ end
+
+ it 'does not make API call' do
+ expect(Llm::Config).not_to receive(:with_api_key)
+ service.send(:make_api_call, model: model, messages: messages)
+ end
+ end
+
+ context 'when API key is not configured' do
+ before do
+ InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.destroy
+ # Clear memoized api_key
+ service.instance_variable_set(:@api_key, nil)
+ end
+
+ it 'returns api key missing error' do
+ result = service.send(:make_api_call, model: model, messages: messages)
+
+ expect(result[:error]).to eq(I18n.t('captain.api_key_missing'))
+ expect(result[:error_code]).to eq(401)
+ end
+
+ it 'does not make API call' do
+ expect(Llm::Config).not_to receive(:with_api_key)
+ service.send(:make_api_call, model: model, messages: messages)
+ end
+ end
+
+ it 'calls execute_ruby_llm_request with correct parameters' do
+ expect(service).to receive(:execute_ruby_llm_request).with(model: model, messages: messages).and_call_original
+ service.send(:make_api_call, model: model, messages: messages)
+ end
+
+ it 'instruments the LLM call' do
+ expect(service).to receive(:instrument_llm_call).and_call_original
+ service.send(:make_api_call, model: model, messages: messages)
+ end
+
+ it 'returns formatted response with tokens' do
+ result = service.send(:make_api_call, model: model, messages: messages)
+
+ expect(result[:message]).to eq('Response')
+ expect(result[:usage]['prompt_tokens']).to eq(10)
+ expect(result[:usage]['completion_tokens']).to eq(20)
+ expect(result[:usage]['total_tokens']).to eq(30)
+ end
+ end
+
+ describe 'chat setup' do
+ let(:model) { 'gpt-4' }
+ let(:mock_chat) { instance_double(RubyLLM::Chat) }
+ let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
+ let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) }
+
+ before do
+ allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
+ allow(mock_response).to receive(:input_tokens).and_return(10)
+ allow(mock_response).to receive(:output_tokens).and_return(20)
+ end
+
+ context 'with system instructions' do
+ let(:messages) { [{ role: 'system', content: 'You are helpful' }, { role: 'user', content: 'Hello' }] }
+
+ it 'applies system instructions to chat' do
+ expect(mock_chat).to receive(:with_instructions).with('You are helpful')
+ expect(mock_chat).to receive(:ask).with('Hello').and_return(mock_response)
+
+ service.send(:make_api_call, model: model, messages: messages)
+ end
+ end
+
+ context 'with conversation history' do
+ let(:messages) do
+ [
+ { role: 'system', content: 'You are helpful' },
+ { role: 'user', content: 'First message' },
+ { role: 'assistant', content: 'First response' },
+ { role: 'user', content: 'Second message' }
+ ]
+ end
+
+ it 'adds conversation history before asking' do
+ expect(mock_chat).to receive(:with_instructions).with('You are helpful')
+ expect(mock_chat).to receive(:add_message).with(role: :user, content: 'First message').ordered
+ expect(mock_chat).to receive(:add_message).with(role: :assistant, content: 'First response').ordered
+ expect(mock_chat).to receive(:ask).with('Second message').and_return(mock_response)
+
+ service.send(:make_api_call, model: model, messages: messages)
+ end
+ end
+
+ context 'with single message' do
+ let(:messages) { [{ role: 'system', content: 'You are helpful' }, { role: 'user', content: 'Hello' }] }
+
+ it 'does not add conversation history' do
+ expect(mock_chat).to receive(:with_instructions).with('You are helpful')
+ expect(mock_chat).not_to receive(:add_message)
+ expect(mock_chat).to receive(:ask).with('Hello').and_return(mock_response)
+
+ service.send(:make_api_call, model: model, messages: messages)
+ end
+ end
+ end
+
+ describe 'error handling' do
+ let(:model) { 'gpt-4' }
+ let(:messages) { [{ role: 'user', content: 'Hello' }] }
+ let(:error) { StandardError.new('API Error') }
+ let(:exception_tracker) { instance_double(ChatwootExceptionTracker) }
+
+ before do
+ allow(Llm::Config).to receive(:with_api_key).and_raise(error)
+ allow(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
+ allow(exception_tracker).to receive(:capture_exception)
+ end
+
+ it 'tracks exceptions' do
+ expect(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
+ expect(exception_tracker).to receive(:capture_exception)
+
+ service.send(:make_api_call, model: model, messages: messages)
+ end
+
+ it 'returns error response' do
+ expect(exception_tracker).to receive(:capture_exception)
+ result = service.send(:make_api_call, model: model, messages: messages)
+
+ expect(result[:error]).to eq('API Error')
+ expect(result[:request_messages]).to eq(messages)
+ end
+ end
+
+ describe '#api_key' do
+ context 'when openai hook is configured' do
+ let(:hook) { create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' }) }
+
+ before { hook }
+
+ it 'uses api key from hook' do
+ expect(service.send(:api_key)).to eq('hook-key')
+ end
+ end
+
+ context 'when openai hook is not configured' do
+ it 'uses system api key' do
+ expect(service.send(:api_key)).to eq('test-key')
+ end
+ end
+ end
+
+ describe '#prompt_from_file' do
+ it 'reads prompt from file' do
+ allow(Rails.root).to receive(:join).and_return(instance_double(Pathname, read: 'Test prompt content'))
+ expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content')
+ end
+ end
+
+ describe '#extract_original_context' do
+ it 'returns the most recent user message' do
+ messages = [
+ { role: 'user', content: 'First question' },
+ { role: 'assistant', content: 'First response' },
+ { role: 'user', content: 'Follow-up question' }
+ ]
+
+ result = service.send(:extract_original_context, messages)
+ expect(result).to eq('Follow-up question')
+ end
+
+ it 'returns nil when no user messages exist' do
+ messages = [
+ { role: 'system', content: 'System prompt' },
+ { role: 'assistant', content: 'Response' }
+ ]
+
+ result = service.send(:extract_original_context, messages)
+ expect(result).to be_nil
+ end
+
+ it 'returns the only user message when there is just one' do
+ messages = [
+ { role: 'system', content: 'System prompt' },
+ { role: 'user', content: 'Single question' }
+ ]
+
+ result = service.send(:extract_original_context, messages)
+ expect(result).to eq('Single question')
+ end
+ end
+end
diff --git a/spec/lib/captain/follow_up_service_spec.rb b/spec/lib/captain/follow_up_service_spec.rb
new file mode 100644
index 000000000..9e330efdc
--- /dev/null
+++ b/spec/lib/captain/follow_up_service_spec.rb
@@ -0,0 +1,164 @@
+require 'rails_helper'
+
+RSpec.describe Captain::FollowUpService do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:user_message) { 'Make it more concise' }
+ let(:follow_up_context) do
+ {
+ 'event_name' => 'professional',
+ 'original_context' => 'Please help me with this issue',
+ 'last_response' => 'I would be happy to assist you with this matter.',
+ 'conversation_history' => [
+ { 'role' => 'user', 'content' => 'Make it shorter' },
+ { 'role' => 'assistant', 'content' => 'Happy to help with this.' }
+ ]
+ }
+ end
+ let(:service) do
+ described_class.new(
+ account: account,
+ follow_up_context: follow_up_context,
+ user_message: user_message,
+ conversation_display_id: conversation.display_id
+ )
+ end
+
+ before do
+ # Stub captain enabled check to allow specs to test base functionality
+ # without enterprise module interference
+ allow(account).to receive(:feature_enabled?).and_call_original
+ allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ end
+
+ describe '#perform' do
+ context 'when conversation_display_id is provided' do
+ it 'resolves conversation for instrumentation' do
+ expect(service.send(:conversation)).to eq(conversation)
+ end
+ end
+
+ context 'when follow-up context exists' do
+ it 'constructs messages array with full conversation history' do
+ expect(service).to receive(:make_api_call) do |args|
+ messages = args[:messages]
+
+ expect(messages).to match(
+ [
+ a_hash_including(role: 'system', content: include('tone rewrite (professional)')),
+ { role: 'user', content: 'Please help me with this issue' },
+ { role: 'assistant', content: 'I would be happy to assist you with this matter.' },
+ { role: 'user', content: 'Make it shorter' },
+ { role: 'assistant', content: 'Happy to help with this.' },
+ { role: 'user', content: 'Make it more concise' }
+ ]
+ )
+
+ { message: 'Refined response' }
+ end
+
+ service.perform
+ end
+
+ it 'returns updated follow-up context' do
+ allow(service).to receive(:make_api_call).and_return({ message: 'Refined response' })
+
+ result = service.perform
+
+ expect(result[:message]).to eq('Refined response')
+ expect(result[:follow_up_context]['last_response']).to eq('Refined response')
+ expect(result[:follow_up_context]['conversation_history'].length).to eq(4)
+ expect(result[:follow_up_context]['conversation_history'][-2]['content']).to eq('Make it more concise')
+ expect(result[:follow_up_context]['conversation_history'][-1]['content']).to eq('Refined response')
+ end
+ end
+
+ context 'when follow-up context is missing' do
+ let(:follow_up_context) { nil }
+
+ it 'returns error with 400 code' do
+ result = service.perform
+
+ expect(result[:error]).to eq('Follow-up context missing')
+ expect(result[:error_code]).to eq(400)
+ end
+ end
+ end
+
+ describe '#build_follow_up_system_prompt' do
+ it 'describes tone rewrite actions' do
+ %w[professional casual friendly confident straightforward].each do |tone|
+ session = { 'event_name' => tone }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include("tone rewrite (#{tone})")
+ expect(prompt).to include('help them refine the result')
+ end
+ end
+
+ it 'describes fix_spelling_grammar action' do
+ session = { 'event_name' => 'fix_spelling_grammar' }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include('spelling and grammar correction')
+ end
+
+ it 'describes improve action' do
+ session = { 'event_name' => 'improve' }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include('message improvement')
+ end
+
+ it 'describes summarize action' do
+ session = { 'event_name' => 'summarize' }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include('conversation summary')
+ end
+
+ it 'describes reply_suggestion action' do
+ session = { 'event_name' => 'reply_suggestion' }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include('reply suggestion')
+ end
+
+ it 'describes label_suggestion action' do
+ session = { 'event_name' => 'label_suggestion' }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include('label suggestion')
+ end
+
+ it 'uses event_name directly for unknown actions' do
+ session = { 'event_name' => 'custom_action' }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include('custom_action')
+ end
+ end
+
+ describe '#describe_previous_action' do
+ it 'returns tone description for tone operations' do
+ expect(service.send(:describe_previous_action, 'professional')).to eq('tone rewrite (professional)')
+ expect(service.send(:describe_previous_action, 'casual')).to eq('tone rewrite (casual)')
+ expect(service.send(:describe_previous_action, 'friendly')).to eq('tone rewrite (friendly)')
+ expect(service.send(:describe_previous_action, 'confident')).to eq('tone rewrite (confident)')
+ expect(service.send(:describe_previous_action, 'straightforward')).to eq('tone rewrite (straightforward)')
+ end
+
+ it 'returns specific descriptions for other operations' do
+ expect(service.send(:describe_previous_action, 'fix_spelling_grammar')).to eq('spelling and grammar correction')
+ expect(service.send(:describe_previous_action, 'improve')).to eq('message improvement')
+ expect(service.send(:describe_previous_action, 'summarize')).to eq('conversation summary')
+ expect(service.send(:describe_previous_action, 'reply_suggestion')).to eq('reply suggestion')
+ expect(service.send(:describe_previous_action, 'label_suggestion')).to eq('label suggestion')
+ end
+
+ it 'returns event name for unknown operations' do
+ expect(service.send(:describe_previous_action, 'unknown')).to eq('unknown')
+ end
+ end
+end
diff --git a/spec/lib/captain/label_suggestion_service_spec.rb b/spec/lib/captain/label_suggestion_service_spec.rb
new file mode 100644
index 000000000..0c40b103c
--- /dev/null
+++ b/spec/lib/captain/label_suggestion_service_spec.rb
@@ -0,0 +1,169 @@
+require 'rails_helper'
+
+RSpec.describe Captain::LabelSuggestionService do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:label1) { create(:label, account: account, title: 'bug') }
+ let(:label2) { create(:label, account: account, title: 'feature-request') }
+ let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
+ let(:mock_chat) { instance_double(RubyLLM::Chat) }
+ let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
+ let(:mock_response) { instance_double(RubyLLM::Message, content: 'bug, feature-request', input_tokens: 100, output_tokens: 20) }
+
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ label1
+ label2
+ allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
+ allow(mock_chat).to receive(:with_instructions)
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
+ # Stub captain enabled check to allow specs to test base functionality
+ # without enterprise module interference
+ allow(account).to receive(:feature_enabled?).and_call_original
+ allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ end
+
+ describe '#label_suggestion_message' do
+ context 'with valid conversation' do
+ before do
+ # Create enough incoming messages to pass validation
+ 3.times do |i|
+ create(:message, conversation: conversation, message_type: :incoming,
+ content: "Message #{i}", created_at: i.minutes.ago)
+ end
+ end
+
+ it 'returns label suggestions' do
+ result = service.perform
+
+ expect(result[:message]).to eq('bug, feature-request')
+ end
+
+ it 'removes "Labels:" prefix from response' do
+ allow(mock_response).to receive(:content).and_return('Labels: bug, feature-request')
+
+ result = service.perform
+
+ expect(result[:message]).to eq(' bug, feature-request')
+ end
+
+ it 'removes "Label:" prefix (singular) from response' do
+ allow(mock_response).to receive(:content).and_return('label: bug')
+
+ result = service.perform
+
+ expect(result[:message]).to eq(' bug')
+ end
+
+ it 'builds labels_with_messages format correctly' do
+ expect(service).to receive(:make_api_call) do |args|
+ user_message = args[:messages].find { |m| m[:role] == 'user' }[:content]
+
+ expect(user_message).to include('Messages:')
+ expect(user_message).to include('Labels:')
+ expect(user_message).to include('bug, feature-request')
+ { message: 'bug' }
+ end
+
+ service.perform
+ end
+ end
+
+ context 'with invalid conversation' do
+ it 'returns nil when conversation has less than 3 incoming messages' do
+ create(:message, conversation: conversation, message_type: :incoming, content: 'Message 1')
+ create(:message, conversation: conversation, message_type: :incoming, content: 'Message 2')
+
+ result = service.perform
+
+ expect(result).to be_nil
+ end
+
+ it 'returns nil when conversation has more than 100 messages' do
+ 101.times do |i|
+ create(:message, conversation: conversation, message_type: :incoming, content: "Message #{i}")
+ end
+
+ result = service.perform
+
+ expect(result).to be_nil
+ end
+
+ it 'returns nil when conversation has >20 messages and last is not incoming' do
+ 21.times do |i|
+ create(:message, conversation: conversation, message_type: :incoming, content: "Message #{i}")
+ end
+ create(:message, conversation: conversation, message_type: :outgoing, content: 'Agent reply')
+
+ result = service.perform
+
+ expect(result).to be_nil
+ end
+ end
+
+ context 'when caching' do
+ before do
+ 3.times do |i|
+ create(:message, conversation: conversation, message_type: :incoming,
+ content: "Message #{i}", created_at: i.minutes.ago)
+ end
+ end
+
+ it 'reads from cache on cache hit' do
+ # Warm up cache
+ service.perform
+
+ # Create new service instance to test cache read
+ new_service = described_class.new(account: account, conversation_display_id: conversation.display_id)
+
+ expect(new_service).not_to receive(:make_api_call)
+ result = new_service.perform
+
+ expect(result[:message]).to eq('bug, feature-request')
+ end
+
+ it 'writes to cache on cache miss' do
+ expect(Redis::Alfred).to receive(:setex).and_call_original
+
+ service.perform
+ end
+
+ it 'returns nil for invalid cached JSON' do
+ # Set invalid JSON in cache
+ cache_key = service.send(:cache_key)
+ Redis::Alfred.set(cache_key, 'invalid json')
+
+ result = service.perform
+
+ # Should make API call since cache read failed
+ expect(result[:message]).to eq('bug, feature-request')
+ end
+
+ it 'does not cache error responses' do
+ error_response = { error: 'API Error', request_messages: [] }
+ allow(service).to receive(:make_api_call).and_return(error_response)
+
+ expect(Redis::Alfred).not_to receive(:setex)
+
+ service.perform
+ end
+ end
+
+ context 'when no labels exist' do
+ before do
+ Label.destroy_all
+ 3.times do |i|
+ create(:message, conversation: conversation, message_type: :incoming,
+ content: "Message #{i}")
+ end
+ end
+
+ it 'returns nil' do
+ result = service.perform
+
+ expect(result).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/lib/captain/reply_suggestion_service_spec.rb b/spec/lib/captain/reply_suggestion_service_spec.rb
new file mode 100644
index 000000000..81c1f3854
--- /dev/null
+++ b/spec/lib/captain/reply_suggestion_service_spec.rb
@@ -0,0 +1,92 @@
+require 'rails_helper'
+
+RSpec.describe Captain::ReplySuggestionService do
+ subject(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id, user: agent) }
+
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, account: account, name: 'Jane Smith') }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:captured_messages) { [] }
+
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ create(:message, conversation: conversation, message_type: :incoming, content: 'I need help')
+ allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+
+ mock_response = instance_double(RubyLLM::Message, content: 'Sure, I can help!', input_tokens: 50, output_tokens: 20)
+ mock_chat = instance_double(RubyLLM::Chat)
+ mock_context = instance_double(RubyLLM::Context, chat: mock_chat)
+
+ allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
+ allow(mock_chat).to receive(:with_instructions) { |msg| captured_messages << { role: 'system', content: msg } }
+ allow(mock_chat).to receive(:add_message) { |args| captured_messages << args }
+ allow(mock_chat).to receive(:ask) do |msg|
+ captured_messages << { role: 'user', content: msg }
+ mock_response
+ end
+ end
+
+ describe '#perform' do
+ it 'returns the suggested reply' do
+ result = service.perform
+
+ expect(result[:message]).to eq('Sure, I can help!')
+ end
+
+ it 'formats conversation using LlmFormatter' do
+ service.perform
+
+ user_message = captured_messages.find { |m| m[:role] == 'user' }
+ expect(user_message[:content]).to include('Message History:')
+ expect(user_message[:content]).to include('User: I need help')
+ end
+
+ context 'with chat channel' do
+ it 'uses chat-specific instructions' do
+ service.perform
+
+ system_prompt = captured_messages.find { |m| m[:role] == 'system' }[:content]
+ expect(system_prompt).to include('CHAT conversation')
+ expect(system_prompt).to include('brief, conversational')
+ expect(system_prompt).not_to include('EMAIL conversation')
+ end
+ end
+
+ context 'with email channel' do
+ let(:email_channel) { create(:channel_email, account: account) }
+ let(:inbox) { create(:inbox, account: account, channel: email_channel) }
+
+ it 'uses email-specific instructions' do
+ service.perform
+
+ system_prompt = captured_messages.find { |m| m[:role] == 'system' }[:content]
+ expect(system_prompt).to include('EMAIL conversation')
+ expect(system_prompt).to include('professional email')
+ expect(system_prompt).not_to include('CHAT conversation')
+ end
+
+ context 'when agent has a signature' do
+ let(:agent) { create(:user, account: account, name: 'Jane Smith', message_signature: "Best,\nJane Smith") }
+
+ it 'includes the signature in the prompt' do
+ service.perform
+
+ system_prompt = captured_messages.find { |m| m[:role] == 'system' }[:content]
+ expect(system_prompt).to include("Best,\nJane Smith")
+ end
+ end
+
+ context 'when agent has no signature' do
+ let(:agent) { create(:user, account: account, name: 'Jane Smith', message_signature: nil) }
+
+ it 'falls back to agent name for sign-off' do
+ service.perform
+
+ system_prompt = captured_messages.find { |m| m[:role] == 'system' }[:content]
+ expect(system_prompt).to include("sign-off using the agent's name: Jane Smith")
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/captain/rewrite_service_spec.rb b/spec/lib/captain/rewrite_service_spec.rb
new file mode 100644
index 000000000..3c1d7997a
--- /dev/null
+++ b/spec/lib/captain/rewrite_service_spec.rb
@@ -0,0 +1,166 @@
+require 'rails_helper'
+
+RSpec.describe Captain::RewriteService do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:content) { 'I need help with my order' }
+ let(:operation) { 'fix_spelling_grammar' }
+ let(:service) { described_class.new(account: account, content: content, operation: operation, conversation_display_id: conversation.display_id) }
+ let(:mock_chat) { instance_double(RubyLLM::Chat) }
+ let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
+ let(:mock_response) { instance_double(RubyLLM::Message, content: 'Rewritten text', input_tokens: 10, output_tokens: 5) }
+
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
+ allow(mock_chat).to receive(:with_instructions)
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
+ # Stub captain enabled check to allow specs to test base functionality
+ # without enterprise module interference
+ allow(account).to receive(:feature_enabled?).and_call_original
+ allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ end
+
+ describe '#perform with fix_spelling_grammar operation' do
+ let(:operation) { 'fix_spelling_grammar' }
+
+ it 'uses fix_spelling_grammar prompt' do
+ expect(service).to receive(:prompt_from_file).with('fix_spelling_grammar').and_return('Fix errors')
+
+ expect(service).to receive(:make_api_call) do |args|
+ expect(args[:messages][0][:content]).to eq('Fix errors')
+ expect(args[:messages][1][:content]).to eq(content)
+ { message: 'Fixed' }
+ end
+
+ service.perform
+ end
+ end
+
+ describe 'tone rewrite methods' do
+ let(:tone_prompt_template) { 'Rewrite in {{ tone }} tone' }
+
+ before do
+ allow(service).to receive(:prompt_from_file).with('tone_rewrite').and_return(tone_prompt_template)
+ end
+
+ describe '#perform with casual operation' do
+ let(:operation) { 'casual' }
+
+ it 'uses casual tone' do
+ expect(service).to receive(:make_api_call) do |args|
+ expect(args[:messages][0][:content]).to eq('Rewrite in casual tone')
+ { message: 'Hey, need help?' }
+ end
+
+ service.perform
+ end
+ end
+
+ describe '#perform with professional operation' do
+ let(:operation) { 'professional' }
+
+ it 'uses professional tone' do
+ expect(service).to receive(:make_api_call) do |args|
+ expect(args[:messages][0][:content]).to eq('Rewrite in professional tone')
+ { message: 'Professional text' }
+ end
+
+ service.perform
+ end
+ end
+
+ describe '#perform with friendly operation' do
+ let(:operation) { 'friendly' }
+
+ it 'uses friendly tone' do
+ expect(service).to receive(:make_api_call) do |args|
+ expect(args[:messages][0][:content]).to eq('Rewrite in friendly tone')
+ { message: 'Friendly text' }
+ end
+
+ service.perform
+ end
+ end
+
+ describe '#perform with confident operation' do
+ let(:operation) { 'confident' }
+
+ it 'uses confident tone' do
+ expect(service).to receive(:make_api_call) do |args|
+ expect(args[:messages][0][:content]).to eq('Rewrite in confident tone')
+ { message: 'Confident text' }
+ end
+
+ service.perform
+ end
+ end
+
+ describe '#perform with straightforward operation' do
+ let(:operation) { 'straightforward' }
+
+ it 'uses straightforward tone' do
+ expect(service).to receive(:make_api_call) do |args|
+ expect(args[:messages][0][:content]).to eq('Rewrite in straightforward tone')
+ { message: 'Straightforward text' }
+ end
+
+ service.perform
+ end
+ end
+ end
+
+ describe '#perform with improve operation' do
+ let(:operation) { 'improve' }
+ let(:improve_template) { 'Context: {{ conversation_context }}\nDraft: {{ draft_message }}' }
+
+ before do
+ create(:message, conversation: conversation, message_type: :incoming, content: 'Customer message')
+ allow(service).to receive(:prompt_from_file).with('improve').and_return(improve_template)
+ end
+
+ it 'uses conversation context and draft message with Liquid template' do
+ expect(service).to receive(:make_api_call) do |args|
+ system_content = args[:messages][0][:content]
+
+ expect(system_content).to include('Context:')
+ expect(system_content).to include('Draft: I need help with my order')
+ expect(args[:messages][1][:content]).to eq(content)
+ { message: 'Improved text' }
+ end
+
+ service.perform
+ end
+
+ it 'returns formatted response' do
+ result = service.perform
+
+ expect(result[:message]).to eq('Rewritten text')
+ end
+ end
+
+ describe '#perform with invalid operation' do
+ it 'raises ArgumentError for unknown operation' do
+ invalid_service = described_class.new(
+ account: account,
+ content: content,
+ operation: 'invalid_operation',
+ conversation_display_id: conversation.display_id
+ )
+
+ expect { invalid_service.perform }.to raise_error(ArgumentError, /Invalid operation/)
+ end
+
+ it 'prevents method injection attacks' do
+ dangerous_service = described_class.new(
+ account: account,
+ content: content,
+ operation: 'perform',
+ conversation_display_id: conversation.display_id
+ )
+
+ expect { dangerous_service.perform }.to raise_error(ArgumentError, /Invalid operation/)
+ end
+ end
+end
diff --git a/spec/lib/captain/summary_service_spec.rb b/spec/lib/captain/summary_service_spec.rb
new file mode 100644
index 000000000..6da3f122b
--- /dev/null
+++ b/spec/lib/captain/summary_service_spec.rb
@@ -0,0 +1,55 @@
+require 'rails_helper'
+
+RSpec.describe Captain::SummaryService do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
+ let(:mock_chat) { instance_double(RubyLLM::Chat) }
+ let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
+ let(:mock_response) { instance_double(RubyLLM::Message, content: 'Summary of conversation', input_tokens: 100, output_tokens: 50) }
+
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
+ allow(mock_chat).to receive(:with_instructions)
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
+ # Stub captain enabled check to allow specs to test base functionality
+ # without enterprise module interference
+ allow(account).to receive(:feature_enabled?).and_call_original
+ allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ end
+
+ describe '#perform' do
+ it 'passes correct model to API' do
+ expect(service).to receive(:make_api_call).with(
+ hash_including(model: Captain::BaseTaskService::GPT_MODEL)
+ ).and_call_original
+
+ service.perform
+ end
+
+ it 'passes system prompt and conversation text as messages' do
+ allow(service).to receive(:prompt_from_file).with('summary').and_return('Summarize this')
+
+ expect(service).to receive(:make_api_call) do |args|
+ expect(args[:messages].length).to eq(2)
+ expect(args[:messages][0][:role]).to eq('system')
+ expect(args[:messages][0][:content]).to eq('Summarize this')
+ expect(args[:messages][1][:role]).to eq('user')
+ expect(args[:messages][1][:content]).to be_a(String)
+ { message: 'Summary' }
+ end
+
+ service.perform
+ end
+
+ it 'returns formatted response' do
+ result = service.perform
+
+ expect(result[:message]).to eq('Summary of conversation')
+ expect(result[:usage]['prompt_tokens']).to eq(100)
+ expect(result[:usage]['completion_tokens']).to eq(50)
+ end
+ end
+end
diff --git a/spec/lib/integrations/openai/processor_service_spec.rb b/spec/lib/integrations/openai/processor_service_spec.rb
deleted file mode 100644
index 28488cc15..000000000
--- a/spec/lib/integrations/openai/processor_service_spec.rb
+++ /dev/null
@@ -1,201 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe Integrations::Openai::ProcessorService do
- subject(:service) { described_class.new(hook: hook, event: event) }
-
- let(:account) { create(:account) }
- let(:hook) { create(:integrations_hook, :openai, account: account) }
-
- # Mock RubyLLM objects
- let(:mock_chat) { instance_double(RubyLLM::Chat) }
- let(:mock_context) { instance_double(RubyLLM::Context) }
- let(:mock_config) { OpenStruct.new }
- let(:mock_response) do
- instance_double(
- RubyLLM::Message,
- content: 'This is a reply from openai.',
- input_tokens: nil,
- output_tokens: nil
- )
- end
- let(:mock_response_with_usage) do
- instance_double(
- RubyLLM::Message,
- content: 'This is a reply from openai.',
- input_tokens: 50,
- output_tokens: 20
- )
- end
-
- before do
- allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context)
- allow(mock_context).to receive(:chat).and_return(mock_chat)
-
- allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
- allow(mock_chat).to receive(:add_message).and_return(mock_chat)
- allow(mock_chat).to receive(:ask).and_return(mock_response)
- end
-
- describe '#perform' do
- describe 'text transformation operations' do
- shared_examples 'text transformation operation' do |event_name|
- let(:event) { { 'name' => event_name, 'data' => { 'content' => 'This is a test' } } }
-
- it 'returns the transformed text' do
- result = service.perform
- expect(result[:message]).to eq('This is a reply from openai.')
- end
-
- it 'sends the user content to the LLM' do
- service.perform
- expect(mock_chat).to have_received(:ask).with('This is a test')
- end
-
- it 'sets system instructions' do
- service.perform
- expect(mock_chat).to have_received(:with_instructions).with(a_string_including('You are a helpful support agent'))
- end
- end
-
- it_behaves_like 'text transformation operation', 'rephrase'
- it_behaves_like 'text transformation operation', 'fix_spelling_grammar'
- it_behaves_like 'text transformation operation', 'shorten'
- it_behaves_like 'text transformation operation', 'expand'
- it_behaves_like 'text transformation operation', 'make_friendly'
- it_behaves_like 'text transformation operation', 'make_formal'
- it_behaves_like 'text transformation operation', 'simplify'
- end
-
- describe 'conversation-based operations' do
- let!(:conversation) { create(:conversation, account: account) }
-
- before do
- create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent')
- create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer')
- end
-
- context 'with reply_suggestion event' do
- let(:event) { { 'name' => 'reply_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
-
- it 'returns the suggested reply' do
- result = service.perform
- expect(result[:message]).to eq('This is a reply from openai.')
- end
-
- it 'adds conversation history before asking' do
- service.perform
- # Should add the first message as history, then ask with the last message
- expect(mock_chat).to have_received(:add_message).with(role: :user, content: 'hello agent')
- expect(mock_chat).to have_received(:ask).with('hello customer')
- end
- end
-
- context 'with summarize event' do
- let(:event) { { 'name' => 'summarize', 'data' => { 'conversation_display_id' => conversation.display_id } } }
-
- it 'returns the summary' do
- result = service.perform
- expect(result[:message]).to eq('This is a reply from openai.')
- end
-
- it 'sends formatted conversation as a single message' do
- service.perform
- # Summarize sends conversation as a formatted string in one user message
- expect(mock_chat).to have_received(:ask).with(a_string_matching(/Customer.*hello agent.*Agent.*hello customer/m))
- end
- end
-
- context 'with label_suggestion event and no labels' do
- let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
-
- it 'returns nil' do
- expect(service.perform).to be_nil
- end
- end
- end
-
- describe 'edge cases' do
- context 'with unknown event name' do
- let(:event) { { 'name' => 'unknown', 'data' => {} } }
-
- it 'returns nil' do
- expect(service.perform).to be_nil
- end
- end
- end
-
- describe 'response structure' do
- let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } }
-
- context 'when response includes usage data' do
- before do
- allow(mock_chat).to receive(:ask).and_return(mock_response_with_usage)
- end
-
- it 'returns message with usage data' do
- result = service.perform
-
- expect(result[:message]).to eq('This is a reply from openai.')
- expect(result[:usage]['prompt_tokens']).to eq(50)
- expect(result[:usage]['completion_tokens']).to eq(20)
- expect(result[:usage]['total_tokens']).to eq(70)
- end
-
- it 'includes request_messages in response' do
- result = service.perform
-
- expect(result[:request_messages]).to be_an(Array)
- expect(result[:request_messages].length).to eq(2)
- end
- end
-
- context 'when response does not include usage data' do
- it 'returns message with zero total tokens' do
- result = service.perform
-
- expect(result[:message]).to eq('This is a reply from openai.')
- expect(result[:usage]['total_tokens']).to eq(0)
- end
-
- it 'includes request_messages in response' do
- result = service.perform
-
- expect(result[:request_messages]).to be_an(Array)
- end
- end
- end
-
- describe 'endpoint configuration' do
- let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } }
-
- context 'without CAPTAIN_OPEN_AI_ENDPOINT configured' do
- before { InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy }
-
- it 'uses default OpenAI endpoint' do
- expect(Llm::Config).to receive(:with_api_key).with(
- hook.settings['api_key'],
- api_base: 'https://api.openai.com/v1'
- ).and_call_original
-
- service.perform
- end
- end
-
- context 'with CAPTAIN_OPEN_AI_ENDPOINT configured' do
- before do
- InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy
- create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/')
- end
-
- it 'uses custom endpoint' do
- expect(Llm::Config).to receive(:with_api_key).with(
- hook.settings['api_key'],
- api_base: 'https://custom.azure.com/v1'
- ).and_call_original
-
- service.perform
- end
- end
- end
- end
-end
diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb
index b46c984d1..dcc010d88 100644
--- a/spec/models/channel/whatsapp_spec.rb
+++ b/spec/models/channel/whatsapp_spec.rb
@@ -47,16 +47,39 @@ RSpec.describe Channel::Whatsapp do
end
describe 'webhook_verify_token' do
+ before do
+ # Stub webhook setup to prevent HTTP calls during channel creation
+ setup_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
+ allow(setup_service).to receive(:perform)
+ end
+
it 'generates webhook_verify_token if not present' do
- channel = create(:channel_whatsapp, provider_config: { webhook_verify_token: nil }, provider: 'whatsapp_cloud', account: create(:account),
- validate_provider_config: false, sync_templates: false)
+ channel = create(:channel_whatsapp,
+ provider_config: {
+ 'webhook_verify_token' => nil,
+ 'api_key' => 'test_key',
+ 'business_account_id' => '123456789'
+ },
+ provider: 'whatsapp_cloud',
+ account: create(:account),
+ validate_provider_config: false,
+ sync_templates: false)
expect(channel.provider_config['webhook_verify_token']).not_to be_nil
end
it 'does not generate webhook_verify_token if present' do
- channel = create(:channel_whatsapp, provider: 'whatsapp_cloud', provider_config: { webhook_verify_token: '123' }, account: create(:account),
- validate_provider_config: false, sync_templates: false)
+ channel = create(:channel_whatsapp,
+ provider: 'whatsapp_cloud',
+ provider_config: {
+ 'webhook_verify_token' => '123',
+ 'api_key' => 'test_key',
+ 'business_account_id' => '123456789'
+ },
+ account: create(:account),
+ validate_provider_config: false,
+ sync_templates: false)
expect(channel.provider_config['webhook_verify_token']).to eq '123'
end
@@ -91,15 +114,18 @@ RSpec.describe Channel::Whatsapp do
end
context 'when channel is created through manual setup' do
- it 'does not setup webhooks' do
- expect(Whatsapp::WebhookSetupService).not_to receive(:new)
+ it 'setups webhooks via after_commit callback' do
+ expect(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ expect(webhook_service).to receive(:perform)
+ # Explicitly set source to nil to test manual setup behavior (not embedded_signup)
create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
'business_account_id' => 'test_waba_id',
- 'api_key' => 'test_access_token'
+ 'api_key' => 'test_access_token',
+ 'source' => nil
},
validate_provider_config: false,
sync_templates: false)
@@ -157,12 +183,17 @@ RSpec.describe Channel::Whatsapp do
end
context 'when channel is not embedded_signup' do
- it 'does not call WebhookTeardownService on destroy' do
+ it 'calls WebhookTeardownService on destroy' do
+ # Mock the setup service to prevent HTTP calls during creation
+ setup_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
+ allow(setup_service).to receive(:perform)
+
channel = create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
- 'source' => 'manual',
+ 'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token'
},
validate_provider_config: false,
diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb
index 5a4acf329..e1883b54d 100644
--- a/spec/models/conversation_spec.rb
+++ b/spec/models/conversation_spec.rb
@@ -390,6 +390,20 @@ RSpec.describe Conversation do
.to(have_been_enqueued.at_least(:once).with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
message_type: :activity, content: "#{user.name} has muted the conversation" }))
end
+
+ context 'when contact is missing' do
+ before do
+ conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
+ end
+
+ it 'does not change conversation status' do
+ expect { mute! }.not_to(change { conversation.reload.status })
+ end
+
+ it 'does not enqueue an activity message' do
+ expect { mute! }.not_to have_enqueued_job(Conversations::ActivityMessageJob)
+ end
+ end
end
describe '#unmute!' do
@@ -418,6 +432,22 @@ RSpec.describe Conversation do
.to(have_been_enqueued.at_least(:once).with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
message_type: :activity, content: "#{user.name} has unmuted the conversation" }))
end
+
+ context 'when contact is missing' do
+ let(:conversation) { create(:conversation) }
+
+ before do
+ conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
+ end
+
+ it 'does not change conversation status' do
+ expect { unmute! }.not_to(change { conversation.reload.status })
+ end
+
+ it 'does not enqueue an activity message' do
+ expect { unmute! }.not_to have_enqueued_job(Conversations::ActivityMessageJob)
+ end
+ end
end
describe '#muted?' do
@@ -433,6 +463,16 @@ RSpec.describe Conversation do
it 'returns false if conversation is not muted' do
expect(muted?).to be(false)
end
+
+ context 'when contact is missing' do
+ before do
+ conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
+ end
+
+ it 'returns false' do
+ expect(muted?).to be(false)
+ end
+ end
end
describe 'unread_messages' do
diff --git a/spec/models/integrations/hook_spec.rb b/spec/models/integrations/hook_spec.rb
index 9c2eba73f..fd6e69bbc 100644
--- a/spec/models/integrations/hook_spec.rb
+++ b/spec/models/integrations/hook_spec.rb
@@ -31,27 +31,6 @@ RSpec.describe Integrations::Hook do
end
end
- describe 'process_event' do
- let(:account) { create(:account) }
- let(:params) { { event: 'rephrase', payload: { test: 'test' } } }
-
- it 'returns no processor found for hooks with out processor defined' do
- hook = create(:integrations_hook, account: account)
- expect(hook.process_event(params)).to eq({ :error => 'No processor found' })
- end
-
- it 'returns results from procesor for openai hook' do
- hook = create(:integrations_hook, :openai, account: account)
-
- openai_double = double
- allow(Integrations::Openai::ProcessorService).to receive(:new).and_return(openai_double)
- allow(openai_double).to receive(:perform).and_return('test')
- expect(hook.process_event(params)).to eq('test')
- expect(Integrations::Openai::ProcessorService).to have_received(:new).with(event: params, hook: hook)
- expect(openai_double).to have_received(:perform)
- end
- end
-
describe 'scopes' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
diff --git a/spec/services/csat_survey_service_spec.rb b/spec/services/csat_survey_service_spec.rb
index 6359fbda1..5a62e32e5 100644
--- a/spec/services/csat_survey_service_spec.rb
+++ b/spec/services/csat_survey_service_spec.rb
@@ -88,6 +88,25 @@ describe CsatSurveyService do
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
end
+
+ context 'when survey rules block sending' do
+ before do
+ inbox.update(csat_config: {
+ 'survey_rules' => {
+ 'operator' => 'does_not_contain',
+ 'values' => ['bot-detectado']
+ }
+ })
+ conversation.update(label_list: ['bot-detectado'])
+ end
+
+ it 'does not send CSAT' do
+ service.perform
+
+ expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
+ expect(conversation.messages.where(content_type: :input_csat)).to be_empty
+ end
+ end
end
context 'when it is a WhatsApp channel' do
@@ -306,6 +325,29 @@ describe CsatSurveyService do
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
end
end
+
+ context 'when survey rules block sending' do
+ before do
+ whatsapp_inbox.update(csat_config: {
+ 'template' => { 'name' => 'customer_survey_template', 'language' => 'en' },
+ 'message' => 'Please rate your experience',
+ 'survey_rules' => {
+ 'operator' => 'does_not_contain',
+ 'values' => ['bot-detectado']
+ }
+ })
+ whatsapp_conversation.update(label_list: ['bot-detectado'])
+ end
+
+ it 'does not call WhatsApp template or create a CSAT message' do
+ expect(mock_provider_service).not_to receive(:get_template_status)
+ expect(mock_provider_service).not_to receive(:send_template)
+
+ whatsapp_service.perform
+
+ expect(whatsapp_conversation.messages.where(content_type: :input_csat)).to be_empty
+ 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 a2cae684b..837a30012 100644
--- a/spec/services/message_templates/template/csat_survey_spec.rb
+++ b/spec/services/message_templates/template/csat_survey_spec.rb
@@ -17,83 +17,24 @@ describe MessageTemplates::Template::CsatSurvey do
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]
+ context 'when csat config is provided' do
+ let(:csat_config) do
+ {
+ 'display_type' => 'star',
+ 'message' => 'Please rate your experience'
}
- }
- end
+ 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])
+ before { inbox.update(csat_config: csat_config) }
+ it 'creates a CSAT message with configured attributes' do
service.perform
- expect(conversation.messages.template.count).to eq(1)
- message = conversation.messages.template.first
+ message = conversation.messages.template.last
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)
+ expect(message.content_attributes['display_type']).to eq('star')
end
end
end
diff --git a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb
index b162250bf..2ac3bb651 100644
--- a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb
+++ b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb
@@ -41,10 +41,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
it 'increments reauthorization count if fetching attachment fails' do
stub_request(
:get,
- whatsapp_channel.media_url(
- 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
- whatsapp_channel.provider_config['phone_number_id']
- )
+ whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')
).to_return(
status: 401
)
@@ -112,10 +109,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
def stub_media_url_request
stub_request(
:get,
- whatsapp_channel.media_url(
- 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
- whatsapp_channel.provider_config['phone_number_id']
- )
+ whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')
).to_return(
status: 200,
body: {
diff --git a/spec/services/whatsapp/webhook_setup_service_spec.rb b/spec/services/whatsapp/webhook_setup_service_spec.rb
index 38856e252..d35d14cb9 100644
--- a/spec/services/whatsapp/webhook_setup_service_spec.rb
+++ b/spec/services/whatsapp/webhook_setup_service_spec.rb
@@ -6,7 +6,8 @@ describe Whatsapp::WebhookSetupService do
phone_number: '+1234567890',
provider_config: {
'phone_number_id' => '123456789',
- 'webhook_verify_token' => 'test_verify_token'
+ 'webhook_verify_token' => 'test_verify_token',
+ 'source' => 'embedded_signup'
},
provider: 'whatsapp_cloud',
sync_templates: false,
@@ -261,7 +262,8 @@ describe Whatsapp::WebhookSetupService do
'phone_number_id' => '123456789',
'webhook_verify_token' => 'existing_verify_token',
'business_id' => 'existing_business_id',
- 'waba_id' => 'existing_waba_id'
+ 'waba_id' => 'existing_waba_id',
+ 'source' => 'embedded_signup'
},
provider: 'whatsapp_cloud',
sync_templates: false,
diff --git a/tailwind.config.js b/tailwind.config.js
index 5ac5c5836..b788d54d5 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -259,6 +259,7 @@ const tailwindConfig = {
'ph',
'material-symbols',
'teenyicons',
+ 'fluent',
]),
},
}),
diff --git a/theme/colors.js b/theme/colors.js
index 52870e75b..47c786308 100644
--- a/theme/colors.js
+++ b/theme/colors.js
@@ -210,6 +210,21 @@ export const colors = {
12: 'rgb(var(--gray-12) / )',
},
+ violet: {
+ 1: 'rgb(var(--violet-1) / )',
+ 2: 'rgb(var(--violet-2) / )',
+ 3: 'rgb(var(--violet-3) / )',
+ 4: 'rgb(var(--violet-4) / )',
+ 5: 'rgb(var(--violet-5) / )',
+ 6: 'rgb(var(--violet-6) / )',
+ 7: 'rgb(var(--violet-7) / )',
+ 8: 'rgb(var(--violet-8) / )',
+ 9: 'rgb(var(--violet-9) / )',
+ 10: 'rgb(var(--violet-10) / )',
+ 11: 'rgb(var(--violet-11) / )',
+ 12: 'rgb(var(--violet-12) / )',
+ },
+
black: '#000000',
brand: '#2781F6',
background: 'rgb(var(--background-color) / )',