diff --git a/Gemfile b/Gemfile
index ecbd9a193..97df33260 100644
--- a/Gemfile
+++ b/Gemfile
@@ -192,7 +192,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
-gem 'ai-agents'
+gem 'ai-agents', '>= 0.9.1'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
diff --git a/Gemfile.lock b/Gemfile.lock
index d38c29bbd..8b948fba1 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
- ai-agents (0.9.0)
+ ai-agents (0.9.1)
ruby_llm (~> 1.9.1)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
@@ -1027,7 +1027,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
- ai-agents
+ ai-agents (>= 0.9.1)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
index bb5dab680..d7244616d 100644
--- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
+++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
@@ -1,6 +1,7 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :validate_whatsapp_channel
+ before_action :validate_captain_enabled, only: [:analyze]
def show
service = CsatTemplateManagementService.new(@inbox)
@@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
+ def analyze
+ template_params = extract_template_params
+ return render_missing_message_error if template_params[:message].blank?
+
+ result = CsatTemplateUtilityAnalysisService.new(
+ account: Current.account,
+ inbox: @inbox,
+ message: template_params[:message],
+ button_text: template_params[:button_text],
+ language: template_params[:language]
+ ).perform
+
+ render json: result
+ rescue ActionController::ParameterMissing
+ render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
+ end
+
private
def fetch_inbox
@@ -46,6 +64,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
+ def validate_captain_enabled
+ return if Current.account.feature_enabled?('captain_integration')
+
+ render json: { error: 'Captain is required for template analysis' }, status: :forbidden
+ end
+
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb
index d734a346e..3d6b559c8 100644
--- a/app/helpers/api/v1/inboxes_helper.rb
+++ b/app/helpers/api/v1/inboxes_helper.rb
@@ -57,39 +57,35 @@ module Api::V1::InboxesHelper
end
def check_smtp_connection(channel_data, smtp)
+ smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
channel_data[:smtp_authentication]&.to_sym || :login)
smtp.finish
+ rescue Net::SMTPAuthenticationError
+ raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error')
+ rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
+ raise StandardError, I18n.t('errors.inboxes.smtp.connection_error')
+ rescue OpenSSL::SSL::SSLError
+ raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error')
+ rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError
+ raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error')
+ rescue StandardError => e
+ raise StandardError, e.message
end
def set_smtp_encryption(channel_data, smtp)
if channel_data[:smtp_enable_ssl_tls]
- set_enable_tls(channel_data, smtp)
+ set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
elsif channel_data[:smtp_enable_starttls_auto]
- set_enable_starttls_auto(channel_data, smtp)
+ set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
end
end
- def set_enable_starttls_auto(channel_data, smtp)
- return unless smtp.respond_to?(:enable_starttls_auto)
+ def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
+ return unless smtp.respond_to?(method)
- if channel_data[:smtp_openssl_verify_mode]
- context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
- smtp.enable_starttls_auto(context)
- else
- smtp.enable_starttls_auto
- end
- end
-
- def set_enable_tls(channel_data, smtp)
- return unless smtp.respond_to?(:enable_tls)
-
- if channel_data[:smtp_openssl_verify_mode]
- context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
- smtp.enable_tls(context)
- else
- smtp.enable_tls
- end
+ context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
+ context ? smtp.send(method, context) : smtp.send(method)
end
def enable_openssl_mode(smtp_openssl_verify_mode)
diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js
index 83ba3e9ba..079f21815 100644
--- a/app/javascript/dashboard/api/inboxes.js
+++ b/app/javascript/dashboard/api/inboxes.js
@@ -42,6 +42,12 @@ class Inboxes extends CacheEnabledApiClient {
getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
+
+ analyzeCSATTemplateUtility(inboxId, template) {
+ return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
+ template,
+ });
+ }
}
export default new Inboxes();
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue
index 3dc29738e..66296257f 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue
@@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
+ TELEGRAM: 'i-ri-telegram-fill',
TIKTOK: 'i-ri-tiktok-fill',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
@@ -66,6 +67,7 @@ const defaultState = {
facebook: '',
github: '',
instagram: '',
+ telegram: '',
tiktok: '',
linkedin: '',
twitter: '',
@@ -103,9 +105,13 @@ const prepareStateBasedOnProps = () => {
countryCode = '',
country = '',
city = '',
+ socialTelegramUserName = '',
socialProfiles = {},
} = additionalAttributes || {};
+ const telegramUsername =
+ socialProfiles?.telegram || socialTelegramUserName || '';
+
Object.assign(state, {
id,
name,
@@ -119,7 +125,10 @@ const prepareStateBasedOnProps = () => {
countryCode,
country,
city,
- socialProfiles,
+ socialProfiles: {
+ ...socialProfiles,
+ telegram: telegramUsername,
+ },
},
});
};
diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue b/app/javascript/dashboard/components-next/radioCard/RadioCard.story.vue
similarity index 97%
rename from app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue
rename to app/javascript/dashboard/components-next/radioCard/RadioCard.story.vue
index df1f8655c..636ded048 100644
--- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue
+++ b/app/javascript/dashboard/components-next/radioCard/RadioCard.story.vue
@@ -1,6 +1,6 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue
index 249c53abb..abd2552bf 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue
@@ -2,7 +2,7 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'next/avatar/Avatar.vue';
-import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
+import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
const props = defineProps({
senderNameType: {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue
index 03cb6dada..dc247da6f 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useInbox } from 'dashboard/composables/useInbox';
+import { useCaptain } from 'dashboard/composables/useCaptain';
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
@@ -26,6 +27,7 @@ const props = defineProps({
const { t } = useI18n();
const store = useStore();
const labels = useMapGetter('labels/getLabels');
+const { captainEnabled } = useCaptain();
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
props.inbox?.id
@@ -37,6 +39,8 @@ const isAnyWhatsAppChannel = computed(
);
const isUpdating = ref(false);
+const utilityAnalysisLoading = ref(false);
+const utilityAnalysisResult = ref(null);
const selectedLabelValues = ref([]);
const currentLabel = ref('');
@@ -46,7 +50,7 @@ const state = reactive({
message: '',
templateButtonText: 'Please rate us',
surveyRuleOperator: 'contains',
- templateLanguage: '',
+ templateLanguage: 'en',
});
const templateStatus = ref(null);
@@ -89,6 +93,9 @@ const messagePreviewData = computed(() => ({
const shouldShowTemplateStatus = computed(
() => templateStatus.value && !templateLoading.value
);
+const showUtilityAnalyzer = computed(
+ () => isAnyWhatsAppChannel.value && captainEnabled.value
+);
const templateApprovalStatus = computed(() => {
const statusMap = {
@@ -218,6 +225,85 @@ const updateDisplayType = type => {
state.displayType = type;
};
+const resetUtilityAnalysis = () => {
+ utilityAnalysisResult.value = null;
+};
+
+const analyzeTemplateUtility = async () => {
+ if (!showUtilityAnalyzer.value || !state.message?.trim()) return;
+
+ utilityAnalysisLoading.value = true;
+ resetUtilityAnalysis();
+
+ try {
+ const response = await store.dispatch(
+ 'inboxes/analyzeCSATTemplateUtility',
+ {
+ inboxId: props.inbox.id,
+ template: {
+ message: state.message,
+ button_text: state.templateButtonText,
+ language: state.templateLanguage,
+ },
+ }
+ );
+ utilityAnalysisResult.value = response;
+ } catch (error) {
+ const errorMessage =
+ error.response?.data?.error ||
+ t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ERROR_MESSAGE');
+ useAlert(errorMessage);
+ } finally {
+ utilityAnalysisLoading.value = false;
+ }
+};
+
+const applyUtilitySuggestion = () => {
+ const suggestion = utilityAnalysisResult.value?.optimized_message;
+ if (!suggestion) return;
+
+ state.message = suggestion;
+ resetUtilityAnalysis();
+};
+
+watch(
+ () => [state.message, state.templateButtonText, state.templateLanguage],
+ (newValues, oldValues) => {
+ if (!oldValues || !utilityAnalysisResult.value) {
+ return;
+ }
+
+ const changed = newValues.some(
+ (value, index) => value !== oldValues[index]
+ );
+ if (changed) {
+ resetUtilityAnalysis();
+ }
+ }
+);
+
+const getUtilityClassificationLabel = classification => {
+ if (classification === 'LIKELY_UTILITY') {
+ return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_UTILITY');
+ }
+ if (classification === 'LIKELY_MARKETING') {
+ return t(
+ 'INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_MARKETING'
+ );
+ }
+ return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.UNCLEAR');
+};
+
+const getUtilityClassificationClass = classification => {
+ if (classification === 'LIKELY_UTILITY') {
+ return 'bg-n-teal-3 text-n-teal-11';
+ }
+ if (classification === 'LIKELY_MARKETING') {
+ return 'bg-n-ruby-3 text-n-ruby-11';
+ }
+ return 'bg-n-amber-3 text-n-amber-11';
+};
+
const updateSurveyRuleOperator = operator => {
state.surveyRuleOperator = operator;
};
@@ -450,6 +536,70 @@ const handleConfirmTemplateUpdate = async () => {
class="w-full"
/>
+
+
+
+ {{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.HELPER_NOTE') }}
+
+
+
+
+
+
+ {{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.RESULT_LABEL') }}
+
+
+ {{
+ getUtilityClassificationLabel(
+ utilityAnalysisResult.classification
+ )
+ }}
+
+
+
+ {{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.GUIDANCE_NOTE') }}
+
+
+
+ {{
+ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.SUGGESTION_LABEL')
+ }}
+
+
+ {{ utilityAnalysisResult.optimized_message }}
+
+
+
+
{
+ const response = await InboxesAPI.analyzeCSATTemplateUtility(
+ inboxId,
+ template
+ );
+ return response.data;
+ },
};
export const mutations = {
diff --git a/app/models/concerns/activity_message_handler.rb b/app/models/concerns/activity_message_handler.rb
index c25aba47f..0300bd2d1 100644
--- a/app/models/concerns/activity_message_handler.rb
+++ b/app/models/concerns/activity_message_handler.rb
@@ -1,6 +1,7 @@
module ActivityMessageHandler
extend ActiveSupport::Concern
+ include AssigneeActivityMessageHandler
include PriorityActivityMessageHandler
include LabelActivityMessageHandler
include SlaActivityMessageHandler
@@ -104,27 +105,6 @@ module ActivityMessageHandler
content = I18n.t("conversations.activity.#{change_type}", user_name: Current.user.name)
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
-
- def generate_assignee_change_activity_content(user_name)
- params = { assignee_name: assignee&.name || '', user_name: user_name }
- key = assignee_id ? 'assigned' : 'removed'
- key = 'self_assigned' if self_assign? assignee_id
- I18n.t("conversations.activity.assignee.#{key}", **params)
- end
-
- def create_assignee_change_activity(user_name)
- user_name = activity_message_owner(user_name)
-
- return unless user_name
-
- content = generate_assignee_change_activity_content(user_name)
- ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
- end
-
- def activity_message_owner(user_name)
- user_name = I18n.t('automation.system_name') if !user_name && Current.executed_by.present?
- user_name
- end
end
ActivityMessageHandler.prepend_mod_with('ActivityMessageHandler')
diff --git a/app/models/concerns/assignee_activity_message_handler.rb b/app/models/concerns/assignee_activity_message_handler.rb
new file mode 100644
index 000000000..ff5516ec9
--- /dev/null
+++ b/app/models/concerns/assignee_activity_message_handler.rb
@@ -0,0 +1,35 @@
+module AssigneeActivityMessageHandler
+ extend ActiveSupport::Concern
+
+ private
+
+ def create_assignee_change_activity(user_name)
+ user_name = activity_message_owner(user_name)
+
+ return unless user_name
+
+ content = generate_assignee_change_activity_content(user_name)
+ ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
+ end
+
+ def generate_assignee_change_activity_content(user_name)
+ params = { assignee_name: assignee&.name || '', user_name: user_name }
+ key = assignee_id ? 'assigned' : 'removed'
+ key = 'self_assigned' if self_assign? assignee_id
+ I18n.t("conversations.activity.assignee.#{key}", **params)
+ end
+
+ def activity_message_owner(user_name)
+ if !user_name && Current.executed_by.present?
+ user_name = case Current.executed_by
+ when AssignmentPolicy
+ I18n.t('auto_assignment.policy_actor', policy_name: Current.executed_by.name)
+ when Inbox
+ I18n.t('auto_assignment.default_policy_name')
+ else
+ I18n.t('automation.system_name')
+ end
+ end
+ user_name
+ end
+end
diff --git a/app/models/concerns/message_filter_helpers.rb b/app/models/concerns/message_filter_helpers.rb
index 38124f347..39ee71089 100644
--- a/app/models/concerns/message_filter_helpers.rb
+++ b/app/models/concerns/message_filter_helpers.rb
@@ -14,7 +14,7 @@ module MessageFilterHelpers
end
def notifiable?
- incoming? || outgoing?
+ (incoming? || outgoing?) && !private?
end
def conversation_transcriptable?
diff --git a/app/services/auto_assignment/assignment_service.rb b/app/services/auto_assignment/assignment_service.rb
index 89eff9d1c..e27f1e829 100644
--- a/app/services/auto_assignment/assignment_service.rb
+++ b/app/services/auto_assignment/assignment_service.rb
@@ -72,13 +72,17 @@ class AutoAssignment::AssignmentService
end
def assign_conversation(conversation, agent)
+ Current.executed_by = inbox.assignment_policy || inbox
conversation.update!(assignee: agent)
+ Current.executed_by = nil
rate_limiter = build_rate_limiter(agent)
rate_limiter.track_assignment(conversation)
dispatch_assignment_event(conversation, agent)
true
+ ensure
+ Current.executed_by = nil
end
def dispatch_assignment_event(conversation, agent)
diff --git a/app/services/auto_assignment/rate_limiter.rb b/app/services/auto_assignment/rate_limiter.rb
index da8daac9c..b46148526 100644
--- a/app/services/auto_assignment/rate_limiter.rb
+++ b/app/services/auto_assignment/rate_limiter.rb
@@ -2,8 +2,6 @@ class AutoAssignment::RateLimiter
pattr_initialize [:inbox!, :agent!]
def within_limit?
- return true unless enabled?
-
current_count < limit
end
@@ -13,24 +11,18 @@ class AutoAssignment::RateLimiter
end
def current_count
- return 0 unless enabled?
-
pattern = assignment_key_pattern
Redis::Alfred.keys_count(pattern)
end
private
- def enabled?
- config.present? && limit.positive?
- end
-
def limit
- config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY
+ config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : 5
end
def window
- config&.fair_distribution_window&.to_i || 24.hours.to_i
+ config&.fair_distribution_window&.to_i || 5.minutes.to_i
end
def config
diff --git a/app/services/csat_template_management_service.rb b/app/services/csat_template_management_service.rb
index 064804082..e83b3413a 100644
--- a/app/services/csat_template_management_service.rb
+++ b/app/services/csat_template_management_service.rb
@@ -110,6 +110,7 @@ class CsatTemplateManagementService
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
status_result = template_service.get_template_status(content_sid)
+ return { template_exists: false, error: 'Template not found' } unless status_result.is_a?(Hash)
if status_result[:success]
{
@@ -130,6 +131,7 @@ class CsatTemplateManagementService
def get_whatsapp_template_status(template)
template_name = template['name'] || CsatTemplateNameService.csat_template_name(@inbox.id)
status_result = Whatsapp::CsatTemplateService.new(@inbox.channel).get_template_status(template_name)
+ return { template_exists: false, error: 'Template not found' } unless status_result.is_a?(Hash)
if status_result[:success]
{
diff --git a/app/services/csat_template_utility_analysis_service.rb b/app/services/csat_template_utility_analysis_service.rb
new file mode 100644
index 000000000..b1b089b53
--- /dev/null
+++ b/app/services/csat_template_utility_analysis_service.rb
@@ -0,0 +1,86 @@
+class CsatTemplateUtilityAnalysisService
+ include CsatTemplateUtilityRubric
+
+ pattr_initialize [:account!, :inbox!, :message!, { button_text: nil, language: 'en' }]
+
+ def perform
+ baseline = rule_based_result
+ return baseline if baseline[:classification] == 'LIKELY_MARKETING'
+
+ llm_result = llm_result_or_nil(baseline)
+ llm_result || baseline
+ end
+
+ private
+
+ def llm_result_or_nil(baseline)
+ llm_output = Captain::CsatUtilityAnalysisService.new(
+ account: account,
+ message: message,
+ button_text: button_text,
+ language: language,
+ baseline: baseline
+ ).perform
+
+ return nil if llm_output[:error]
+
+ normalize_llm_result(llm_output, baseline: baseline)
+ rescue StandardError => e
+ Rails.logger.error("CSAT utility LLM analysis failed for inbox #{inbox.id}: #{e.message}")
+ nil
+ end
+
+ def normalize_llm_result(result, baseline:)
+ classification = normalized_classification(result[:classification], baseline: baseline)
+ optimized_message = result[:optimized_message].presence || baseline[:optimized_message]
+ optimized_message = baseline[:optimized_message] if baseline[:classification] == 'LIKELY_MARKETING'
+
+ {
+ classification: classification,
+ optimized_message: optimized_message
+ }
+ end
+
+ def normalized_classification(value, baseline:)
+ raw = value.to_s
+ return 'LIKELY_MARKETING' if baseline[:classification] == 'LIKELY_MARKETING'
+
+ raw
+ end
+
+ def rule_based_result
+ text = sanitized_message
+ marketing_hits_count = MARKETING_PATTERNS.count { |pattern| pattern.match?(text) }
+ utility_hits_count = UTILITY_PATTERNS.count { |pattern| pattern.match?(text) }
+ criteria = evaluate_criteria(text: text, marketing_hits_count: marketing_hits_count)
+ classification = classify(criteria: criteria, utility_hits_count: utility_hits_count)
+ build_rule_payload(
+ classification: classification
+ )
+ end
+
+ def build_rule_payload(payload)
+ {
+ classification: payload[:classification],
+ optimized_message: optimized_message_for(payload[:classification])
+ }
+ end
+
+ def sanitized_message
+ message.to_s.squish
+ end
+
+ def classify(criteria:, utility_hits_count:)
+ return 'LIKELY_MARKETING' unless criteria[:marketing_prohibition]
+ return 'LIKELY_MARKETING' unless criteria[:prohibited_content]
+ return 'LIKELY_UTILITY' if criteria.values.all? && utility_hits_count >= 2
+
+ 'UNCLEAR'
+ end
+
+ def optimized_message_for(classification)
+ return sanitized_message if classification == 'LIKELY_UTILITY'
+
+ build_input_aware_utility_message
+ end
+end
diff --git a/app/services/csat_template_utility_rubric.rb b/app/services/csat_template_utility_rubric.rb
new file mode 100644
index 000000000..0253a003a
--- /dev/null
+++ b/app/services/csat_template_utility_rubric.rb
@@ -0,0 +1,125 @@
+# rubocop:disable Metrics/ModuleLength
+module CsatTemplateUtilityRubric
+ LANGUAGE_FALLBACKS = {
+ 'en' => {
+ support_request: 'support request',
+ support_ticket: 'support ticket',
+ support_conversation: 'support conversation',
+ status_closed: 'closed',
+ status_resolved: 'resolved',
+ status_completed: 'completed',
+ line_status: 'Your %s has been %s.',
+ line_help: 'If you still need help, simply reply to this message.',
+ line_rate: 'To rate this support interaction, please use the button below.'
+ }
+ }.freeze
+
+ MARKETING_PATTERNS = [
+ /\b(discounts?|offers?|promos?|promotions?|deals?|sales?|buy|shop|subscribe)\b/i,
+ /\b(limited\s*time|don't\s*miss|exclusive|special\s*offer)\b/i,
+ /\b(click\s*(here|below)\s*to\s*(buy|get|shop))\b/i,
+ /\b(new\s*(plans?|products?|services?))\b/i
+ ].freeze
+
+ TRANSACTION_TRIGGER_PATTERNS = [
+ /\b(closed|closing|resolved|completed)\b/i,
+ /\b(ticket|request|case|conversation|support)\b/i
+ ].freeze
+
+ TRANSACTIONAL_CONTENT_PATTERNS = [
+ /\b(ticket|request|case|conversation)\b/i,
+ /\b(reply\s+to\s+this\s+message|if\s+you\s+still\s+need\s+help)\b/i,
+ /\b(rate|califica|calificar)\b/i
+ ].freeze
+
+ PROHIBITED_CONTENT_PATTERNS = [
+ /\b(contest|sweepstake|lottery|quiz)\b/i,
+ /\b(password|otp|pin|cvv|credit\s*card)\b/i,
+ /\b(weapon|drugs|gambling)\b/i
+ ].freeze
+
+ STATUS_PATTERNS = {
+ 'closed' => /\b(closed|closing)\b/i,
+ 'resolved' => /\b(resolved|resolve[sd]?)\b/i,
+ 'completed' => /\b(completed|complete[sd]?)\b/i
+ }.freeze
+
+ SUBJECT_PATTERNS = {
+ 'support ticket' => /\b(ticket)\b/i,
+ 'support conversation' => /\b(conversation|chat)\b/i,
+ 'support request' => /\b(request|case|support)\b/i
+ }.freeze
+
+ UTILITY_PATTERNS = [
+ /\b(support|ticket|request|conversation|case)\b/i,
+ /\b(closed|resolved|completed)\b/i,
+ /\b(reply\s+to\s+this\s+message\b)/i,
+ /\b(if\s+you\s+still\s+need\s+help)\b/i,
+ /\b(rate\s+this\s+(support|interaction|conversation))\b/i
+ ].freeze
+
+ private
+
+ def build_input_aware_utility_message
+ text = translation_pack
+ subject = detected_subject
+ status = detected_status
+ intro = extracted_intro_sentence
+
+ parts = []
+ parts << intro if intro.present?
+ parts << format(text[:line_status], subject: subject, status: status)
+ parts << text[:line_help]
+ parts << text[:line_rate]
+ parts.join(' ')
+ end
+
+ def detected_status
+ matched = STATUS_PATTERNS.find { |_key, pattern| pattern.match?(sanitized_message) }
+ status_key = matched&.first || 'closed'
+ translation_pack[:"status_#{status_key}"]
+ end
+
+ def detected_subject
+ matched = SUBJECT_PATTERNS.find { |_key, pattern| pattern.match?(sanitized_message) }
+ subject_key = matched&.first&.tr(' ', '_') || 'support_request'
+ translation_pack[subject_key.to_sym]
+ end
+
+ def extracted_intro_sentence
+ first_sentence = sanitized_message.split(/(?<=[.!?])\s+/).first.to_s
+ return nil if first_sentence.blank?
+ return nil if MARKETING_PATTERNS.any? { |pattern| pattern.match?(first_sentence) }
+ return nil unless first_sentence.match?(/\b(thanks|thank you|hello|hi)\b/i)
+
+ normalized = first_sentence.gsub(/\s+/, ' ').strip
+ normalized.ends_with?('.', '!', '?') ? normalized : "#{normalized}."
+ end
+
+ def translation_pack
+ LANGUAGE_FALLBACKS.fetch(primary_language_code, LANGUAGE_FALLBACKS['en'])
+ end
+
+ def primary_language_code
+ language.to_s.downcase.split(/[-_]/).first
+ end
+
+ def evaluate_criteria(text:, marketing_hits_count:)
+ {
+ trigger: TRANSACTION_TRIGGER_PATTERNS.any? { |pattern| pattern.match?(text) },
+ transactional_content: TRANSACTIONAL_CONTENT_PATTERNS.count { |pattern| pattern.match?(text) } >= 2,
+ marketing_prohibition: marketing_hits_count.zero?,
+ prohibited_content: PROHIBITED_CONTENT_PATTERNS.none? { |pattern| pattern.match?(text) },
+ clarity_and_utility: clear_utility_intent?(text)
+ }
+ end
+
+ def clear_utility_intent?(text)
+ has_support_context = text.match?(/\b(support|ticket|request|case|conversation)\b/i)
+ has_actionable_next_step = text.match?(/\b(reply\s+to\s+this\s+message)\b/i) ||
+ text.match?(/\b(if\s+you\s+still\s+need\s+help)\b/i) ||
+ text.match?(/\b(rate\s+this\s+(support|interaction|conversation))\b/i)
+ has_support_context && has_actionable_next_step
+ end
+end
+# rubocop:enable Metrics/ModuleLength
diff --git a/app/services/line/incoming_message_service.rb b/app/services/line/incoming_message_service.rb
index 6a1192d02..b30eacd7b 100644
--- a/app/services/line/incoming_message_service.rb
+++ b/app/services/line/incoming_message_service.rb
@@ -145,7 +145,12 @@ class Line::IncomingMessageService
end
def set_conversation
- @conversation = @contact_inbox.conversations.first
+ # if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
+ @conversation = if @inbox.lock_to_single_conversation
+ @contact_inbox.conversations.last
+ else
+ @contact_inbox.conversations.where.not(status: :resolved).last
+ end
return if @conversation
@conversation = ::Conversation.create!(conversation_params)
diff --git a/app/services/tiktok/message_service.rb b/app/services/tiktok/message_service.rb
index fcd613ec2..7acbb9486 100644
--- a/app/services/tiktok/message_service.rb
+++ b/app/services/tiktok/message_service.rb
@@ -23,7 +23,12 @@ class Tiktok::MessageService
end
def conversation
- @conversation ||= contact_inbox.conversations.first || create_conversation(channel, contact_inbox, tt_conversation_id)
+ @conversation ||= if channel.inbox.lock_to_single_conversation
+ contact_inbox.conversations.order(created_at: :desc).first
+ else
+ contact_inbox.conversations.where.not(status: :resolved).order(created_at: :desc).first
+ end
+ @conversation ||= create_conversation(channel, contact_inbox, tt_conversation_id)
end
def create_message
diff --git a/app/services/tiktok/messaging_helpers.rb b/app/services/tiktok/messaging_helpers.rb
index 71c417cd7..0f9d9e0b3 100644
--- a/app/services/tiktok/messaging_helpers.rb
+++ b/app/services/tiktok/messaging_helpers.rb
@@ -27,7 +27,15 @@ module Tiktok::MessagingHelpers
end
def find_conversation(channel, tt_conversation_id)
- channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id)&.conversations&.first
+ contact_inbox = channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id)
+ return if contact_inbox.blank?
+
+ if channel.inbox.lock_to_single_conversation
+ contact_inbox.conversations.order(created_at: :desc).first
+ else
+ contact_inbox.conversations.where.not(status: :resolved).order(created_at: :desc).first ||
+ contact_inbox.conversations.order(created_at: :desc).first
+ end
end
def create_conversation(channel, contact_inbox, tt_conversation_id)
diff --git a/app/services/whatsapp/csat_template_service.rb b/app/services/whatsapp/csat_template_service.rb
index 9bdbef8ca..9dc057a10 100644
--- a/app/services/whatsapp/csat_template_service.rb
+++ b/app/services/whatsapp/csat_template_service.rb
@@ -2,7 +2,7 @@ class Whatsapp::CsatTemplateService
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
WHATSAPP_API_VERSION = 'v14.0'.freeze
- TEMPLATE_CATEGORY = 'MARKETING'.freeze
+ TEMPLATE_CATEGORY = 'UTILITY'.freeze
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
def initialize(whatsapp_channel)
diff --git a/config/locales/en.yml b/config/locales/en.yml
index a058d28c7..9cab941c2 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -108,6 +108,11 @@ en:
host_unreachable_error: Host unreachable, Please check the IMAP address, IMAP port and try again.
connection_timed_out_error: Connection timed out for %{address}:%{port}
connection_closed_error: Connection closed.
+ smtp:
+ authentication_error: SMTP authentication failed. Please verify your login credentials.
+ connection_error: Could not connect to SMTP server. Please check the server address and port.
+ ssl_error: SSL/TLS error. Please verify your encryption settings.
+ smtp_error: SMTP server error. Please check your configuration and try again.
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
@@ -433,6 +438,9 @@ en:
seconds:
one: '%{count} second'
other: '%{count} seconds'
+ auto_assignment:
+ default_policy_name: 'Default Policy'
+ policy_actor: 'Automation System via %{policy_name}'
automation:
system_name: 'Automation System'
crm:
diff --git a/config/routes.rb b/config/routes.rb
index 38c93b91e..1efd9f526 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -224,7 +224,9 @@ Rails.application.routes.draw do
end
end
- resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
+ resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do
+ post :analyze, on: :collection
+ end
end
resources :inbox_members, only: [:create, :show], param: :inbox_id do
diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb
index 9aeee605f..72c44024f 100644
--- a/enterprise/app/services/captain/assistant/agent_runner_service.rb
+++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb
@@ -3,6 +3,8 @@ require 'agents/instrumentation'
class Captain::Assistant::AgentRunnerService
include Integrations::LlmInstrumentationConstants
+ include Captain::Assistant::RunnerCallbacksHelper
+ include Captain::Assistant::TracePayloadHelper
CONVERSATION_STATE_ATTRIBUTES = %i[
id display_id inbox_id contact_id status priority
@@ -21,13 +23,7 @@ class Captain::Assistant::AgentRunnerService
end
def generate_response(message_history: [])
- agents = build_and_wire_agents
- context = build_context(message_history)
- message_to_process = extract_last_user_message(message_history)
- runner = Agents::Runner.with_agents(*agents)
- runner = add_usage_metadata_callback(runner)
- runner = add_callbacks_to_runner(runner) if @callbacks.any?
- install_instrumentation(runner)
+ message_to_process, context = run_payload(message_history)
result = runner.run(message_to_process, context: context, max_turns: 100)
process_agent_result(result)
@@ -45,7 +41,10 @@ class Captain::Assistant::AgentRunnerService
def build_context(message_history)
conversation_history = message_history.map do |msg|
- content = extract_text_from_content(msg[:content])
+ content = msg[:content]
+ # Preserve multimodal arrays (with image_url entries) as-is for the runner to restore with attachments.
+ # Only extract text from non-array formats (hashes from agent structured output, plain strings).
+ content = extract_text_from_content(content) unless content.is_a?(Array)
{
role: msg[:role].to_sym,
@@ -63,8 +62,22 @@ class Captain::Assistant::AgentRunnerService
def extract_last_user_message(message_history)
last_user_msg = message_history.reverse.find { |msg| msg[:role] == 'user' }
+ return '' if last_user_msg.blank?
- extract_text_from_content(last_user_msg[:content])
+ content = last_user_msg[:content]
+ return extract_text_from_content(content) unless content.is_a?(Array)
+
+ text, attachments = Captain::OpenAiMessageBuilderService.extract_text_and_attachments(content)
+ return text if attachments.blank?
+
+ RubyLLM::Content.new(text, attachments)
+ end
+
+ def message_history_without_last_user_message(message_history)
+ last_user_index = message_history.rindex { |msg| msg[:role] == 'user' }
+ return message_history if last_user_index.nil?
+
+ message_history.reject.with_index { |_msg, index| index == last_user_index }
end
def extract_text_from_content(content)
@@ -143,28 +156,25 @@ class Captain::Assistant::AgentRunnerService
},
attribute_provider: ->(context_wrapper) { dynamic_trace_attributes(context_wrapper) }
)
+ register_trace_input_callback(runner)
end
def dynamic_trace_attributes(context_wrapper)
state = context_wrapper&.context&.dig(:state) || {}
conversation = state[:conversation] || {}
+ trace_input = context_wrapper&.context&.dig(:captain_v2_trace_input)
+
{
ATTR_LANGFUSE_USER_ID => state[:account_id],
format(ATTR_LANGFUSE_METADATA, 'assistant_id') => state[:assistant_id],
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id],
- format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type]
+ format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type],
+ ATTR_LANGFUSE_TRACE_INPUT => trace_input,
+ ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input
}.compact.transform_values(&:to_s)
end
- def add_callbacks_to_runner(runner)
- runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking]
- runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start]
- runner = add_tool_complete_callback(runner) if @callbacks[:on_tool_complete]
- runner = add_agent_handoff_callback(runner) if @callbacks[:on_agent_handoff]
- runner
- end
-
def add_usage_metadata_callback(runner)
return runner unless ChatwootApp.otel_enabled?
@@ -195,35 +205,20 @@ class Captain::Assistant::AgentRunnerService
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credit_used'), credit_used.to_s)
end
- def add_agent_thinking_callback(runner)
- runner.on_agent_thinking do |*args|
- @callbacks[:on_agent_thinking].call(*args)
- rescue StandardError => e
- Rails.logger.warn "[Captain] Callback error for agent_thinking: #{e.message}"
+ def runner
+ @runner ||= begin
+ configured_runner = Agents::Runner.with_agents(*build_and_wire_agents)
+ configured_runner = add_usage_metadata_callback(configured_runner)
+ configured_runner = add_callbacks_to_runner(configured_runner) if @callbacks.any?
+ install_instrumentation(configured_runner)
+ configured_runner
end
end
- def add_tool_start_callback(runner)
- runner.on_tool_start do |*args|
- @callbacks[:on_tool_start].call(*args)
- rescue StandardError => e
- Rails.logger.warn "[Captain] Callback error for tool_start: #{e.message}"
- end
- end
-
- def add_tool_complete_callback(runner)
- runner.on_tool_complete do |*args|
- @callbacks[:on_tool_complete].call(*args)
- rescue StandardError => e
- Rails.logger.warn "[Captain] Callback error for tool_complete: #{e.message}"
- end
- end
-
- def add_agent_handoff_callback(runner)
- runner.on_agent_handoff do |*args|
- @callbacks[:on_agent_handoff].call(*args)
- rescue StandardError => e
- Rails.logger.warn "[Captain] Callback error for agent_handoff: #{e.message}"
- end
+ def run_payload(message_history)
+ message_to_process = extract_last_user_message(message_history)
+ context = build_context(message_history_without_last_user_message(message_history))
+ enrich_context_with_trace_payload!(context, message_history, message_to_process)
+ [message_to_process, context]
end
end
diff --git a/enterprise/app/services/captain/assistant/runner_callbacks_helper.rb b/enterprise/app/services/captain/assistant/runner_callbacks_helper.rb
new file mode 100644
index 000000000..58fa0ca33
--- /dev/null
+++ b/enterprise/app/services/captain/assistant/runner_callbacks_helper.rb
@@ -0,0 +1,53 @@
+module Captain::Assistant::RunnerCallbacksHelper
+ private
+
+ def add_callbacks_to_runner(runner)
+ runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking]
+ runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start]
+ runner = add_tool_complete_callback(runner) if @callbacks[:on_tool_complete]
+ runner = add_agent_handoff_callback(runner) if @callbacks[:on_agent_handoff]
+ runner
+ end
+
+ def register_trace_input_callback(runner)
+ runner.on_agent_thinking do |_agent_name, _input, context_wrapper|
+ tracing = context_wrapper&.context&.dig(:__otel_tracing)
+ next unless tracing
+
+ trace_input = context_wrapper.context[:captain_v2_trace_current_input]
+ tracing[:pending_llm_input] = trace_input if trace_input.present?
+ end
+ end
+
+ def add_agent_thinking_callback(runner)
+ runner.on_agent_thinking do |*args|
+ @callbacks[:on_agent_thinking].call(*args)
+ rescue StandardError => e
+ Rails.logger.warn "[Captain] Callback error for agent_thinking: #{e.message}"
+ end
+ end
+
+ def add_tool_start_callback(runner)
+ runner.on_tool_start do |*args|
+ @callbacks[:on_tool_start].call(*args)
+ rescue StandardError => e
+ Rails.logger.warn "[Captain] Callback error for tool_start: #{e.message}"
+ end
+ end
+
+ def add_tool_complete_callback(runner)
+ runner.on_tool_complete do |*args|
+ @callbacks[:on_tool_complete].call(*args)
+ rescue StandardError => e
+ Rails.logger.warn "[Captain] Callback error for tool_complete: #{e.message}"
+ end
+ end
+
+ def add_agent_handoff_callback(runner)
+ runner.on_agent_handoff do |*args|
+ @callbacks[:on_agent_handoff].call(*args)
+ rescue StandardError => e
+ Rails.logger.warn "[Captain] Callback error for agent_handoff: #{e.message}"
+ end
+ end
+end
diff --git a/enterprise/app/services/captain/assistant/trace_payload_helper.rb b/enterprise/app/services/captain/assistant/trace_payload_helper.rb
new file mode 100644
index 000000000..3b6546c12
--- /dev/null
+++ b/enterprise/app/services/captain/assistant/trace_payload_helper.rb
@@ -0,0 +1,51 @@
+module Captain::Assistant::TracePayloadHelper
+ private
+
+ def enrich_context_with_trace_payload!(context, message_history, message_to_process)
+ context[:captain_v2_trace_input] = serialize_trace_messages(message_history)
+ context[:captain_v2_trace_current_input] = serialize_trace_content(message_to_process)
+ end
+
+ def serialize_trace_messages(message_history)
+ message_history.map do |message|
+ {
+ role: message[:role].to_s,
+ content: trace_content_payload(message[:content])
+ }
+ end.to_json
+ end
+
+ def serialize_trace_content(content)
+ payload = trace_content_payload(content)
+ return '' if payload.blank?
+
+ payload.is_a?(String) ? payload : payload.to_json
+ end
+
+ def trace_content_payload(content)
+ case content
+ when RubyLLM::Content
+ trace_parts_from_ruby_llm_content(content)
+ when Array, Hash
+ content
+ when NilClass
+ ''
+ else
+ content.to_s
+ end
+ end
+
+ def trace_parts_from_ruby_llm_content(content)
+ parts = []
+ parts << { type: 'text', text: content.text } if content.text.present?
+
+ content.attachments.each do |attachment|
+ parts << { type: 'image_url', image_url: { url: attachment.source.to_s } }
+ end
+
+ return '' if parts.blank?
+ return parts.first[:text] if parts.one? && parts.first[:type] == 'text'
+
+ parts
+ end
+end
diff --git a/enterprise/lib/enterprise/chatwoot_hub.rb b/enterprise/lib/enterprise/chatwoot_hub.rb
new file mode 100644
index 000000000..a9d572d2d
--- /dev/null
+++ b/enterprise/lib/enterprise/chatwoot_hub.rb
@@ -0,0 +1,9 @@
+module Enterprise::ChatwootHub
+ ENTERPRISE_BASE_URL = 'https://hub.2.chatwoot.com'.freeze
+
+ def base_url
+ return ENV.fetch('CHATWOOT_HUB_URL', ENTERPRISE_BASE_URL) if Rails.env.development?
+
+ ENTERPRISE_BASE_URL
+ end
+end
diff --git a/lib/captain/csat_utility_analysis_service.rb b/lib/captain/csat_utility_analysis_service.rb
new file mode 100644
index 000000000..e04a98a7f
--- /dev/null
+++ b/lib/captain/csat_utility_analysis_service.rb
@@ -0,0 +1,66 @@
+class Captain::CsatUtilityAnalysisService < Captain::BaseTaskService
+ pattr_initialize [:account!, :message!, { button_text: nil, language: 'en', baseline: {} }]
+
+ def perform
+ api_response = make_api_call(
+ model: GPT_MODEL,
+ messages: [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: message }
+ ]
+ )
+
+ return api_response if api_response[:error]
+
+ build_result(api_response[:message])
+ end
+
+ private
+
+ def build_result(response_message)
+ parsed = parse_json_response(response_message)
+ return { error: 'Invalid LLM response format' } if parsed.blank?
+
+ core_result(parsed).merge(message: response_message)
+ end
+
+ def core_result(parsed)
+ {
+ classification: normalize_classification(parsed['classification']),
+ optimized_message: parsed['optimized_message'].presence || baseline[:optimized_message]
+ }
+ end
+
+ def system_prompt
+ template = prompt_from_file('csat_utility_analysis')
+ Liquid::Template.parse(template).render(prompt_variables)
+ end
+
+ def prompt_variables
+ {
+ 'message' => message.to_s,
+ 'button_text' => button_text.to_s,
+ 'language' => language.to_s,
+ 'baseline_classification' => baseline[:classification].to_s
+ }
+ end
+
+ def parse_json_response(content)
+ raw = content.to_s.strip
+ json = raw.match(/```json\s*(.*?)\s*```/m)&.captures&.first || raw
+ JSON.parse(json)
+ rescue JSON::ParserError
+ nil
+ end
+
+ def normalize_classification(value)
+ normalized = value.to_s.upcase
+ return normalized if %w[LIKELY_UTILITY LIKELY_MARKETING UNCLEAR].include?(normalized)
+
+ baseline[:classification].presence || 'UNCLEAR'
+ end
+
+ def event_name
+ 'csat_utility_analysis'
+ end
+end
diff --git a/lib/chatwoot_hub.rb b/lib/chatwoot_hub.rb
index c18fb299b..be5a07e05 100644
--- a/lib/chatwoot_hub.rb
+++ b/lib/chatwoot_hub.rb
@@ -1,12 +1,30 @@
# TODO: lets use HTTParty instead of RestClient
class ChatwootHub
- BASE_URL = ENV.fetch('CHATWOOT_HUB_URL', 'https://hub.2.chatwoot.com')
- PING_URL = "#{BASE_URL}/ping".freeze
- REGISTRATION_URL = "#{BASE_URL}/instances".freeze
- PUSH_NOTIFICATION_URL = "#{BASE_URL}/send_push".freeze
- EVENTS_URL = "#{BASE_URL}/events".freeze
- BILLING_URL = "#{BASE_URL}/billing".freeze
- CAPTAIN_ACCOUNTS_URL = "#{BASE_URL}/instance_captain_accounts".freeze
+ DEFAULT_BASE_URL = 'https://hub.2.chatwoot.com'.freeze
+
+ def self.base_url
+ DEFAULT_BASE_URL
+ end
+
+ def self.ping_url
+ "#{base_url}/ping"
+ end
+
+ def self.registration_url
+ "#{base_url}/instances"
+ end
+
+ def self.push_notification_url
+ "#{base_url}/send_push"
+ end
+
+ def self.events_url
+ "#{base_url}/events"
+ end
+
+ def self.billing_base_url
+ "#{base_url}/billing"
+ end
def self.installation_identifier
identifier = InstallationConfig.find_by(name: 'INSTALLATION_IDENTIFIER')&.value
@@ -15,7 +33,7 @@ class ChatwootHub
end
def self.billing_url
- "#{BILLING_URL}?installation_identifier=#{installation_identifier}"
+ "#{billing_base_url}?installation_identifier=#{installation_identifier}"
end
def self.pricing_plan
@@ -68,7 +86,7 @@ class ChatwootHub
begin
info = instance_config
info = info.merge(instance_metrics) unless ENV['DISABLE_TELEMETRY']
- response = RestClient.post(PING_URL, info.to_json, { content_type: :json, accept: :json })
+ response = RestClient.post(ping_url, info.to_json, { content_type: :json, accept: :json })
parsed_response = JSON.parse(response)
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
@@ -80,7 +98,7 @@ class ChatwootHub
def self.register_instance(company_name, owner_name, owner_email)
info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true }
- RestClient.post(REGISTRATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
+ RestClient.post(registration_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e
@@ -89,32 +107,23 @@ class ChatwootHub
def self.send_push(fcm_options)
info = { fcm_options: fcm_options }
- RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
+ RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
- def self.get_captain_settings(account)
- info = {
- installation_identifier: installation_identifier,
- chatwoot_account_id: account.id,
- account_name: account.name
- }
- HTTParty.post(CAPTAIN_ACCOUNTS_URL,
- body: info.to_json,
- headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' })
- end
-
def self.emit_event(event_name, event_data)
return if ENV['DISABLE_TELEMETRY']
info = { event_name: event_name, event_data: event_data }
- RestClient.post(EVENTS_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
+ RestClient.post(events_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
end
+
+ChatwootHub.singleton_class.prepend_mod_with('ChatwootHub')
diff --git a/lib/integrations/openai/openai_prompts/csat_utility_analysis.liquid b/lib/integrations/openai/openai_prompts/csat_utility_analysis.liquid
new file mode 100644
index 000000000..a4638241e
--- /dev/null
+++ b/lib/integrations/openai/openai_prompts/csat_utility_analysis.liquid
@@ -0,0 +1,27 @@
+You are a WhatsApp template compliance assistant.
+Your task is to evaluate whether a CSAT template message is likely to be approved as UTILITY vs MARKETING under Meta policy.
+
+Rules:
+1. Prefer UTILITY only when the message is tied to an existing support or transactional event.
+2. Avoid promotional language, upsell, cross-sell, offers, discounts, or purchase intent.
+3. Keep the rewritten message concise, explicit, and purely transactional.
+4. Do not invent product offers or marketing phrases.
+
+Input:
+- Message: {{ message }}
+- Button text: {{ button_text }}
+- Language code: {{ language }}
+
+Baseline heuristic:
+- Classification: {{ baseline_classification }}
+
+Return ONLY valid JSON with this shape (example):
+{
+ "classification": "LIKELY_UTILITY",
+ "optimized_message": "rewritten utility-safe message"
+}
+
+Allowed values for "classification": "LIKELY_UTILITY", "LIKELY_MARKETING", or "UNCLEAR".
+
+Important:
+- Write `optimized_message` in the same language as `Language code`.
diff --git a/package.json b/package.json
index 37b1307cd..7877a7170 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,7 @@
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.3.6",
- "@chatwoot/utils": "^0.0.51",
+ "@chatwoot/utils": "^0.0.52",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
@@ -95,6 +95,7 @@
"video.js": "7.18.1",
"videojs-record": "4.5.0",
"videojs-wavesurfer": "3.8.0",
+ "virtua": "^0.48.6",
"vue": "^3.5.12",
"vue-chartjs": "5.3.1",
"vue-datepicker-next": "^1.0.3",
@@ -103,7 +104,6 @@
"vue-letter": "^0.2.1",
"vue-router": "~4.4.5",
"vue-upload-component": "^3.1.17",
- "vue-virtual-scroller": "^2.0.0-beta.8",
"vue3-click-away": "^1.2.4",
"vuedraggable": "^4.1.0",
"vuex": "~4.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e880b4aaf..3f038f123 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,8 +26,8 @@ importers:
specifier: 1.3.6
version: 1.3.6
'@chatwoot/utils':
- specifier: ^0.0.51
- version: 0.0.51
+ specifier: ^0.0.52
+ version: 0.0.52
'@formkit/core':
specifier: ^1.6.7
version: 1.6.7
@@ -205,6 +205,9 @@ importers:
videojs-wavesurfer:
specifier: 3.8.0
version: 3.8.0
+ virtua:
+ specifier: ^0.48.6
+ version: 0.48.6(vue@3.5.12(typescript@5.6.2))
vue:
specifier: ^3.5.12
version: 3.5.12(typescript@5.6.2)
@@ -229,9 +232,6 @@ importers:
vue-upload-component:
specifier: ^3.1.17
version: 3.1.17
- vue-virtual-scroller:
- specifier: ^2.0.0-beta.8
- version: 2.0.0-beta.8(vue@3.5.12(typescript@5.6.2))
vue3-click-away:
specifier: ^1.2.4
version: 1.2.4
@@ -457,8 +457,8 @@ packages:
'@chatwoot/prosemirror-schema@1.3.6':
resolution: {integrity: sha512-sHRtWqbtiow9mVF1ixim0eGUXfhGK5tuLOdF9Vf53aepjJ+ngEiNVkxQT6FohlEOd886ZsdQxMvmI92IDaUXAQ==}
- '@chatwoot/utils@0.0.51':
- resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
+ '@chatwoot/utils@0.0.52':
+ resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -3305,9 +3305,6 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
- mitt@2.1.0:
- resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==}
-
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
@@ -4511,6 +4508,26 @@ packages:
videojs-wavesurfer@3.8.0:
resolution: {integrity: sha512-qHucCBiEW+4dZ0Zp1k4R1elprUOV+QDw87UDA9QRXtO7GK/MrSdoe/TMFxP9SLnJCiX9xnYdf4OQgrmvJ9UVVw==}
+ virtua@0.48.6:
+ resolution: {integrity: sha512-Cl4uMvMV5c9RuOy9zhkFMYwx/V4YLBMYLRSWkO8J46opQZ3P7KMq0CqCVOOAKUckjl/r//D2jWTBGYWzmgtzrQ==}
+ peerDependencies:
+ react: '>=16.14.0'
+ react-dom: '>=16.14.0'
+ solid-js: '>=1.0'
+ svelte: '>=5.0'
+ vue: '>=3.2'
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-dom:
+ optional: true
+ solid-js:
+ optional: true
+ svelte:
+ optional: true
+ vue:
+ optional: true
+
vite-node@2.0.1:
resolution: {integrity: sha512-nVd6kyhPAql0s+xIVJzuF+RSRH8ZimNrm6U8ZvTA4MXv8CHI17TFaQwRaFiK75YX6XeFqZD4IoAaAfi9OR1XvQ==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -4625,11 +4642,6 @@ packages:
vue-letter@0.2.1:
resolution: {integrity: sha512-IYWp47XUikjKfEniWYlFxeJFKABZwAE5IEjz866qCBytBr2dzqVDdjoMDpBP//krxkzN/QZYyHe6C09y/IODYg==}
- vue-observe-visibility@2.0.0-alpha.1:
- resolution: {integrity: sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==}
- peerDependencies:
- vue: ^3.0.0
-
vue-resize@2.0.0-alpha.1:
resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==}
peerDependencies:
@@ -4643,11 +4655,6 @@ packages:
vue-upload-component@3.1.17:
resolution: {integrity: sha512-1orTC5apoFzBz4ku2HAydpviaAOck+ABc83rGypIK/Bgl+TqhtoWsQOhXqbb7vDv7pKlvRVWwml9PM224HyhkA==}
- vue-virtual-scroller@2.0.0-beta.8:
- resolution: {integrity: sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==}
- peerDependencies:
- vue: ^3.2.0
-
vue3-click-away@1.2.4:
resolution: {integrity: sha512-O9Z2KlvIhJT8OxaFy04eiZE9rc1Mk/bp+70dLok68ko3Kr8AW5dU+j8avSk4GDQu94FllSr4m5ul4BpzlKOw1A==}
@@ -5010,7 +5017,7 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1
- '@chatwoot/utils@0.0.51':
+ '@chatwoot/utils@0.0.52':
dependencies:
date-fns: 2.30.0
@@ -8226,8 +8233,6 @@ snapshots:
minipass@7.1.2: {}
- mitt@2.1.0: {}
-
mitt@3.0.1: {}
mlly@1.8.0:
@@ -9574,6 +9579,10 @@ snapshots:
video.js: 7.18.1
wavesurfer.js: 7.8.6
+ virtua@0.48.6(vue@3.5.12(typescript@5.6.2)):
+ optionalDependencies:
+ vue: 3.5.12(typescript@5.6.2)
+
vite-node@2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
dependencies:
cac: 6.7.14
@@ -9692,10 +9701,6 @@ snapshots:
dependencies:
lettersanitizer: 1.0.6
- vue-observe-visibility@2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)):
- dependencies:
- vue: 3.5.12(typescript@5.6.2)
-
vue-resize@2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)):
dependencies:
vue: 3.5.12(typescript@5.6.2)
@@ -9707,13 +9712,6 @@ snapshots:
vue-upload-component@3.1.17: {}
- vue-virtual-scroller@2.0.0-beta.8(vue@3.5.12(typescript@5.6.2)):
- dependencies:
- mitt: 2.1.0
- vue: 3.5.12(typescript@5.6.2)
- vue-observe-visibility: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2))
- vue-resize: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2))
-
vue3-click-away@1.2.4: {}
vue@3.5.12(typescript@5.6.2):
diff --git a/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb b/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb
index d577c1b5d..ea23319fe 100644
--- a/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb
@@ -10,10 +10,12 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
let(:web_widget_inbox) { create(:inbox, account: account) }
let(:mock_service) { instance_double(Whatsapp::CsatTemplateService) }
+ let(:analysis_service) { instance_double(CsatTemplateUtilityAnalysisService) }
before do
create(:inbox_member, user: agent, inbox: whatsapp_inbox)
allow(Whatsapp::CsatTemplateService).to receive(:new).and_return(mock_service)
+ allow(CsatTemplateUtilityAnalysisService).to receive(:new).and_return(analysis_service)
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
@@ -380,4 +382,93 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
end
end
end
+
+ describe 'POST /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template/analyze' do
+ let(:valid_template_params) do
+ {
+ template: {
+ message: 'How would you rate your experience?',
+ button_text: 'Rate Us',
+ language: 'en'
+ }
+ }
+ end
+
+ context 'when captain_integration feature is disabled' do
+ before do
+ account.disable_features!('captain_integration')
+ end
+
+ it 'returns forbidden' do
+ post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
+ headers: admin.create_new_auth_token,
+ params: valid_template_params,
+ as: :json
+
+ expect(response).to have_http_status(:forbidden)
+ expect(response.parsed_body['error']).to eq('Captain is required for template analysis')
+ end
+ end
+
+ context 'when captain_integration feature is enabled' do
+ before do
+ account.enable_features!('captain_integration')
+ account.reload
+ end
+
+ it 'returns analysis response' do
+ allow(analysis_service).to receive(:perform).and_return({
+ classification: 'LIKELY_UTILITY',
+ optimized_message: 'Your support request has been closed.'
+ })
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
+ headers: admin.create_new_auth_token,
+ params: valid_template_params,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ response_data = response.parsed_body
+ expect(response_data['classification']).to eq('LIKELY_UTILITY')
+ expect(response_data['optimized_message']).to eq('Your support request has been closed.')
+ end
+
+ it 'returns error when message is missing' do
+ invalid_params = { template: { button_text: 'Rate Us', language: 'en' } }
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
+ headers: admin.create_new_auth_token,
+ params: invalid_params,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Message is required')
+ end
+
+ it 'returns unauthorized when agent is not assigned to inbox' do
+ other_agent = create(:user, account: account, role: :agent)
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
+ headers: other_agent.create_new_auth_token,
+ params: valid_template_params,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'allows access when agent is assigned to inbox' do
+ allow(analysis_service).to receive(:perform).and_return({
+ classification: 'LIKELY_UTILITY',
+ optimized_message: 'Your support request has been closed.'
+ })
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
+ headers: agent.create_new_auth_token,
+ params: valid_template_params,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+ end
end
diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
index fafd6788e..b93ca8ecf 100644
--- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
@@ -631,6 +631,7 @@ RSpec.describe 'Inboxes API', type: :request do
it 'updates smtp configuration with starttls encryption' do
smtp_connection = double
+ allow(smtp_connection).to receive(:open_timeout=).and_return(10)
allow(smtp_connection).to receive(:start).and_return(true)
allow(smtp_connection).to receive(:finish).and_return(true)
allow(smtp_connection).to receive(:respond_to?).and_return(true)
@@ -661,6 +662,7 @@ RSpec.describe 'Inboxes API', type: :request do
it 'updates smtp configuration with ssl/tls encryption' do
smtp_connection = double
+ allow(smtp_connection).to receive(:open_timeout=).and_return(10)
allow(smtp_connection).to receive(:start).and_return(true)
allow(smtp_connection).to receive(:finish).and_return(true)
allow(smtp_connection).to receive(:respond_to?).and_return(true)
@@ -691,6 +693,7 @@ RSpec.describe 'Inboxes API', type: :request do
it 'updates smtp configuration with authentication mechanism' do
smtp_connection = double
+ allow(smtp_connection).to receive(:open_timeout=).and_return(10)
allow(smtp_connection).to receive(:start).and_return(true)
allow(smtp_connection).to receive(:finish).and_return(true)
allow(smtp_connection).to receive(:respond_to?).and_return(true)
diff --git a/spec/controllers/api/v1/accounts/tiktok/authorizations_controller_spec.rb b/spec/controllers/api/v1/accounts/tiktok/authorizations_controller_spec.rb
index 54724aba0..dab4dfad3 100644
--- a/spec/controllers/api/v1/accounts/tiktok/authorizations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/tiktok/authorizations_controller_spec.rb
@@ -32,23 +32,25 @@ RSpec.describe 'TikTok Authorization API', type: :request do
end
it 'creates a new authorization and returns the redirect url' do
- with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do
- post "/api/v1/accounts/#{account.id}/tiktok/authorization",
- headers: administrator.create_new_auth_token,
- as: :json
+ travel_to Time.zone.parse('2025-01-01 00:00:00 UTC') do
+ with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do
+ post "/api/v1/accounts/#{account.id}/tiktok/authorization",
+ headers: administrator.create_new_auth_token,
+ as: :json
+ end
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['success']).to be true
+
+ helper = Class.new do
+ include Tiktok::IntegrationHelper
+ end.new
+
+ expected_state = helper.generate_tiktok_token(account.id)
+ expected_url = Tiktok::AuthClient.authorize_url(state: expected_state)
+
+ expect(response.parsed_body['url']).to eq(expected_url)
end
-
- expect(response).to have_http_status(:success)
- expect(response.parsed_body['success']).to be true
-
- helper = Class.new do
- include Tiktok::IntegrationHelper
- end.new
-
- expected_state = helper.generate_tiktok_token(account.id)
- expected_url = Tiktok::AuthClient.authorize_url(state: expected_state)
-
- expect(response.parsed_body['url']).to eq(expected_url)
end
end
end
diff --git a/spec/enterprise/lib/chatwoot_hub_spec.rb b/spec/enterprise/lib/chatwoot_hub_spec.rb
new file mode 100644
index 000000000..24d78d028
--- /dev/null
+++ b/spec/enterprise/lib/chatwoot_hub_spec.rb
@@ -0,0 +1,21 @@
+require 'rails_helper'
+
+RSpec.describe ChatwootHub do
+ describe '.base_url' do
+ it 'uses the static hub url outside development for enterprise edition' do
+ with_modified_env CHATWOOT_HUB_URL: 'https://custom.example.com' do
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production'))
+
+ expect(described_class.base_url).to eq('https://hub.2.chatwoot.com')
+ end
+ end
+
+ it 'uses CHATWOOT_HUB_URL in development for enterprise edition' do
+ with_modified_env CHATWOOT_HUB_URL: 'https://custom.example.com' do
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development'))
+
+ expect(described_class.base_url).to eq('https://custom.example.com')
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
index c3019b82c..13e4804ce 100644
--- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
+++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
@@ -74,12 +74,11 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
end
it 'runs agent with extracted user message and context' do
- expected_context = {
+ expected_context = hash_including(
session_id: "#{account.id}_#{conversation.display_id}",
conversation_history: [
{ role: :user, content: 'Hello there', agent_name: nil },
- { role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' },
- { role: :user, content: 'I need help with my account', agent_name: nil }
+ { role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' }
],
state: hash_including(
account_id: account.id,
@@ -87,7 +86,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
conversation: hash_including(id: conversation.id),
contact: hash_including(id: contact.id)
)
- }
+ )
expect(mock_runner).to receive(:run).with(
'I need help with my account',
@@ -98,6 +97,71 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
service.generate_response(message_history: message_history)
end
+ context 'when the latest user message is multimodal' do
+ let(:multimodal_message_history) do
+ [
+ { role: 'assistant', content: 'Please share a screenshot' },
+ {
+ role: 'user',
+ content: [
+ { type: 'text', text: 'What does this error mean?' },
+ { type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
+ ]
+ }
+ ]
+ end
+
+ it 'passes image attachments to the runner input' do
+ expect(mock_runner).to receive(:run) do |input, context:, max_turns:|
+ expect(input).to be_a(RubyLLM::Content)
+ expect(input.text).to eq('What does this error mean?')
+ expect(input.attachments.first.source.to_s).to eq('https://example.com/error.png')
+ expect(context[:conversation_history]).to eq([{ role: :assistant, content: 'Please share a screenshot', agent_name: nil }])
+ expect(max_turns).to eq(100)
+ end
+
+ service.generate_response(message_history: multimodal_message_history)
+ end
+
+ it 'preserves multimodal content in earlier history messages' do
+ history_with_prior_image = [
+ {
+ role: 'user',
+ content: [
+ { type: 'text', text: 'Here is my error screenshot' },
+ { type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
+ ]
+ },
+ { role: 'assistant', content: 'I see the error. Try restarting.' },
+ { role: 'user', content: 'It still does not work' }
+ ]
+
+ expect(mock_runner).to receive(:run) do |input, context:, max_turns:|
+ expect(input).to eq('It still does not work')
+ # The earlier user message with the image should preserve the multimodal array
+ first_history_msg = context[:conversation_history].first
+ expect(first_history_msg[:content]).to be_a(Array)
+ expect(first_history_msg[:content]).to include(
+ { type: 'text', text: 'Here is my error screenshot' },
+ { type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
+ )
+ expect(max_turns).to eq(100)
+ end
+
+ service.generate_response(message_history: history_with_prior_image)
+ end
+
+ it 'stores multimodal trace payloads in runner context' do
+ expect(mock_runner).to receive(:run) do |_input, context:, max_turns:|
+ expect(context[:captain_v2_trace_input]).to include('image_url')
+ expect(context[:captain_v2_trace_current_input]).to include('image_url')
+ expect(max_turns).to eq(100)
+ end
+
+ service.generate_response(message_history: multimodal_message_history)
+ end
+ end
+
it 'processes and formats agent result' do
result = service.generate_response(message_history: message_history)
@@ -197,22 +261,21 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
end
context 'with multimodal content' do
- let(:multimodal_message_history) do
+ let(:multimodal_content) do
[
- {
- role: 'user',
- content: [
- { type: 'text', text: 'Can you help with this image?' },
- { type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
- ]
- }
+ { type: 'text', text: 'Can you help with this image?' },
+ { type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
]
end
- it 'extracts text content from multimodal messages' do
+ let(:multimodal_message_history) do
+ [{ role: 'user', content: multimodal_content }]
+ end
+
+ it 'preserves multimodal arrays in conversation history for image context retention' do
context = service.send(:build_context, multimodal_message_history)
- expect(context[:conversation_history].first[:content]).to eq('Can you help with this image?')
+ expect(context[:conversation_history].first[:content]).to eq(multimodal_content)
end
end
end
@@ -225,6 +288,24 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
expect(result).to eq('I need help with my account')
end
+
+ it 'returns multimodal content with image attachments for the runner input' do
+ multimodal_message_history = [
+ {
+ role: 'user',
+ content: [
+ { type: 'text', text: 'Can you check this screenshot?' },
+ { type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
+ ]
+ }
+ ]
+
+ result = service.send(:extract_last_user_message, multimodal_message_history)
+
+ expect(result).to be_a(RubyLLM::Content)
+ expect(result.text).to eq('Can you check this screenshot?')
+ expect(result.attachments.first.source.to_s).to eq('https://example.com/image.jpg')
+ end
end
describe '#extract_text_from_content' do
@@ -256,6 +337,28 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
end
end
+ describe '#dynamic_trace_attributes' do
+ subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
+
+ it 'adds serialized trace input attributes when present in context' do
+ context = {
+ state: {
+ account_id: account.id,
+ assistant_id: assistant.id,
+ conversation: { id: conversation.id, display_id: conversation.display_id }
+ },
+ captain_v2_trace_input: '[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.com/image.jpg"}}]}]'
+ }
+ context_wrapper = Struct.new(:context).new(context)
+
+ attributes = service.send(:dynamic_trace_attributes, context_wrapper)
+
+ expect(attributes['langfuse.trace.input']).to include('image_url')
+ expect(attributes['langfuse.observation.input']).to include('image_url')
+ expect(attributes['langfuse.user.id']).to eq(account.id.to_s)
+ end
+ end
+
describe '#build_state' do
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
diff --git a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb
index 8a64e6fd2..24c7889de 100644
--- a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb
+++ b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb
@@ -44,17 +44,19 @@ describe Enterprise::Billing::TopupCheckoutService do
end
it 'raises error for invalid credits' do
- expect do
- service.create_checkout_session(credits: 500)
- end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error)
+ expect { service.create_checkout_session(credits: 500) }.to raise_error do |error|
+ expect(error.class.name).to eq('Enterprise::Billing::TopupCheckoutService::Error')
+ expect(error.message).to eq(I18n.t('errors.topup.invalid_option'))
+ end
end
it 'raises error when account is on free plan' do
account.update!(custom_attributes: { plan_name: 'Hacker', stripe_customer_id: stripe_customer_id })
- expect do
- service.create_checkout_session(credits: 1000)
- end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error)
+ expect { service.create_checkout_session(credits: 1000) }.to raise_error do |error|
+ expect(error.class.name).to eq('Enterprise::Billing::TopupCheckoutService::Error')
+ expect(error.message).to eq(I18n.t('errors.topup.plan_not_eligible'))
+ end
end
end
end
diff --git a/spec/lib/captain/csat_utility_analysis_service_spec.rb b/spec/lib/captain/csat_utility_analysis_service_spec.rb
new file mode 100644
index 000000000..e4e980e01
--- /dev/null
+++ b/spec/lib/captain/csat_utility_analysis_service_spec.rb
@@ -0,0 +1,24 @@
+require 'rails_helper'
+
+RSpec.describe Captain::CsatUtilityAnalysisService do
+ let(:account) { create(:account) }
+ let(:service) { described_class.new(account: account, message: 'Test message', language: 'en', baseline: {}) }
+
+ describe '#perform' do
+ before do
+ allow(account).to receive(:feature_enabled?).and_call_original
+ allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ allow(service).to receive(:make_api_call).and_return({
+ message: '{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}'
+ })
+ end
+
+ it 'returns parsed payload and preserves raw message for usage metering' do
+ result = service.perform
+
+ expect(result[:classification]).to eq('LIKELY_UTILITY')
+ expect(result[:optimized_message]).to eq('Utility-safe message')
+ expect(result[:message]).to eq('{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}')
+ end
+ end
+end
diff --git a/spec/lib/chatwoot_hub_spec.rb b/spec/lib/chatwoot_hub_spec.rb
index 0f53971af..a1051e619 100644
--- a/spec/lib/chatwoot_hub_spec.rb
+++ b/spec/lib/chatwoot_hub_spec.rb
@@ -1,6 +1,13 @@
require 'rails_helper'
describe ChatwootHub do
+ describe '.base_url' do
+ it 'uses the static hub url' do
+ expect(described_class::DEFAULT_BASE_URL).to eq('https://hub.2.chatwoot.com')
+ expect(described_class.base_url).to eq('https://hub.2.chatwoot.com')
+ end
+ end
+
it 'generates installation identifier' do
installation_identifier = described_class.installation_identifier
expect(installation_identifier).not_to be_nil
@@ -12,7 +19,7 @@ describe ChatwootHub do
version = '1.1.1'
allow(RestClient).to receive(:post).and_return({ version: version }.to_json)
expect(described_class.sync_with_hub['version']).to eq version
- expect(RestClient).to have_received(:post).with(described_class::PING_URL, described_class.instance_config
+ expect(RestClient).to have_received(:post).with(described_class.ping_url, described_class.instance_config
.merge(described_class.instance_metrics).to_json, { content_type: :json, accept: :json })
end
@@ -21,7 +28,7 @@ describe ChatwootHub do
with_modified_env DISABLE_TELEMETRY: 'true' do
allow(RestClient).to receive(:post).and_return({ version: version }.to_json)
expect(described_class.sync_with_hub['version']).to eq version
- expect(RestClient).to have_received(:post).with(described_class::PING_URL,
+ expect(RestClient).to have_received(:post).with(described_class.ping_url,
described_class.instance_config.to_json, { content_type: :json, accept: :json })
end
end
@@ -41,7 +48,7 @@ describe ChatwootHub do
info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true }
allow(RestClient).to receive(:post)
described_class.register_instance(company_name, owner_name, owner_email)
- expect(RestClient).to have_received(:post).with(described_class::REGISTRATION_URL,
+ expect(RestClient).to have_received(:post).with(described_class.registration_url,
info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json })
end
end
@@ -54,7 +61,7 @@ describe ChatwootHub do
info = { event_name: event_name, event_data: event_data }
allow(RestClient).to receive(:post)
described_class.emit_event(event_name, event_data)
- expect(RestClient).to have_received(:post).with(described_class::EVENTS_URL,
+ expect(RestClient).to have_received(:post).with(described_class.events_url,
info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json })
end
@@ -64,29 +71,9 @@ describe ChatwootHub do
allow(RestClient).to receive(:post)
described_class.emit_event(event_name, event_data)
expect(RestClient).not_to have_received(:post)
- .with(described_class::EVENTS_URL,
+ .with(described_class.events_url,
info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json })
end
end
end
-
- context 'when fetching captain settings' do
- it 'returns the captain settings' do
- account = create(:account)
- stub_request(:post, ChatwootHub::CAPTAIN_ACCOUNTS_URL).with(
- body: { installation_identifier: described_class.installation_identifier, chatwoot_account_id: account.id, account_name: account.name }
- ).to_return(
- body: { account_email: 'test@test.com', account_id: '123', access_token: '123', assistant_id: '123' }.to_json
- )
-
- expect(described_class.get_captain_settings(account).body).to eq(
- {
- account_email: 'test@test.com',
- account_id: '123',
- access_token: '123',
- assistant_id: '123'
- }.to_json
- )
- end
- end
end
diff --git a/spec/listeners/notification_listener_spec.rb b/spec/listeners/notification_listener_spec.rb
index 76e458b8e..048339ca5 100644
--- a/spec/listeners/notification_listener_spec.rb
+++ b/spec/listeners/notification_listener_spec.rb
@@ -142,7 +142,24 @@ describe NotificationListener do
expect(first_agent.notifications.first.notification_type).to eq('conversation_mention')
end
- it 'will not create duplicate new message notifications for assignment & participation' do
+ it 'will create a mention notification when a user is mentioned in a private note' do
+ create(:inbox_member, user: first_agent, inbox: inbox)
+
+ message = build(
+ :message,
+ conversation: conversation,
+ account: account,
+ content: "hey [#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name})",
+ private: true
+ )
+ event = Events::Base.new(event_name, Time.zone.now, message: message)
+ listener.message_created(event)
+
+ expect(first_agent.notifications.count).to eq(1)
+ expect(first_agent.notifications.first.notification_type).to eq('conversation_mention')
+ end
+
+ it 'will not create new message notifications for private messages without mentions' do
create(:inbox_member, user: first_agent, inbox: inbox)
conversation.update(assignee: first_agent)
# participants is created by async job. so creating it directly for testcase
@@ -160,8 +177,7 @@ describe NotificationListener do
listener.message_created(event)
expect(conversation.conversation_participants.map(&:user)).to include(first_agent)
- expect(first_agent.notifications.count).to eq(1)
- expect(first_agent.notifications.first.notification_type).to eq('assigned_conversation_new_message')
+ expect(first_agent.notifications.count).to eq(0)
end
end
diff --git a/spec/services/auto_assignment/rate_limiter_spec.rb b/spec/services/auto_assignment/rate_limiter_spec.rb
index d5c3de047..59b145c87 100644
--- a/spec/services/auto_assignment/rate_limiter_spec.rb
+++ b/spec/services/auto_assignment/rate_limiter_spec.rb
@@ -61,7 +61,7 @@ RSpec.describe AutoAssignment::RateLimiter do
it 'still tracks the assignment with default window' do
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
- expect(Redis::Alfred).to receive(:set).with(expected_key, conversation.id.to_s, ex: 24.hours.to_i)
+ expect(Redis::Alfred).to receive(:set).with(expected_key, conversation.id.to_s, ex: 5.minutes.to_i)
rate_limiter.track_assignment(conversation)
end
end
@@ -154,12 +154,12 @@ RSpec.describe AutoAssignment::RateLimiter do
allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
end
- it 'uses the default window value of 24 hours' do
+ it 'uses the default window value of 5 minutes' do
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
expect(Redis::Alfred).to receive(:set).with(
expected_key,
conversation.id.to_s,
- ex: 86_400
+ ex: 5.minutes.to_i
)
rate_limiter.track_assignment(conversation)
end
diff --git a/spec/services/csat_template_utility_analysis_service_spec.rb b/spec/services/csat_template_utility_analysis_service_spec.rb
new file mode 100644
index 000000000..7f5521d4a
--- /dev/null
+++ b/spec/services/csat_template_utility_analysis_service_spec.rb
@@ -0,0 +1,81 @@
+require 'rails_helper'
+
+RSpec.describe CsatTemplateUtilityAnalysisService do
+ let(:account) { build_stubbed(:account) }
+ let(:inbox) { build_stubbed(:inbox) }
+ let(:llm_service) { instance_double(Captain::CsatUtilityAnalysisService) }
+
+ before do
+ allow(Captain::CsatUtilityAnalysisService).to receive(:new).and_return(llm_service)
+ allow(llm_service).to receive(:perform).and_return({ error: 'LLM unavailable' })
+ end
+
+ describe '#perform' do
+ context 'when message is utility-compatible' do
+ it 'returns likely utility classification and keeps original message' do
+ message = 'Your support request has been closed. If you still need help, reply to this message.'
+ result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
+
+ expect(result[:classification]).to eq('LIKELY_UTILITY')
+ expect(result[:optimized_message]).to eq(message)
+ expect(result.keys).to contain_exactly(:classification, :optimized_message)
+ end
+ end
+
+ context 'when message contains marketing intent' do
+ it 'returns likely marketing classification with utility-safe rewrite' do
+ message = 'Please rate us and check out our special offer with a discount.'
+ result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
+
+ expect(result[:classification]).to eq('LIKELY_MARKETING')
+ expect(result[:optimized_message]).to include('support request')
+ expect(result[:optimized_message]).to include('reply to this message')
+ expect(result.keys).to contain_exactly(:classification, :optimized_message)
+ end
+ end
+
+ context 'when language is non-English and fallback rewrite is used' do
+ it 'returns English rewrite content' do
+ message = 'Tu caso está cerrado. Califícanos y no te pierdas nuestra oferta.'
+ result = described_class.new(account: account, inbox: inbox, message: message, language: 'es').perform
+
+ expect(result[:optimized_message]).to include('Your support request has been closed.')
+ expect(result[:optimized_message]).to include('If you still need help')
+ end
+ end
+
+ context 'when llm returns inconsistent marketing classification' do
+ it 'keeps likely marketing classification' do
+ allow(llm_service).to receive(:perform).and_return({
+ classification: 'LIKELY_MARKETING',
+ optimized_message: 'Your support request has been closed.'
+ })
+
+ message = "Your case is closed. Don't miss our limited-time premium offer. Rate us below."
+ result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
+
+ expect(result[:classification]).to eq('LIKELY_MARKETING')
+ end
+ end
+
+ context 'when rules classify as marketing' do
+ it 'short-circuits without calling llm' do
+ expect(llm_service).not_to receive(:perform)
+
+ message = 'Your request is closed. Special offer: subscribe now and save.'
+ result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
+
+ expect(result[:classification]).to eq('LIKELY_MARKETING')
+ end
+ end
+
+ context 'when rules classify as marketing for plural promo terms' do
+ it 'keeps likely marketing classification from baseline rules' do
+ message = 'Thanks for contacting us. Rate us and check out our new plans with special discounts.'
+ result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
+
+ expect(result[:classification]).to eq('LIKELY_MARKETING')
+ end
+ end
+ end
+end
diff --git a/spec/services/line/incoming_message_service_spec.rb b/spec/services/line/incoming_message_service_spec.rb
index a7805ce9b..997777a43 100644
--- a/spec/services/line/incoming_message_service_spec.rb
+++ b/spec/services/line/incoming_message_service_spec.rb
@@ -405,5 +405,111 @@ describe Line::IncomingMessageService do
expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('contacts.csv')
end
end
+
+ context 'when lock_to_single_conversation is false' do
+ before do
+ line_channel.inbox.update(lock_to_single_conversation: false)
+ end
+
+ it 'creates a new conversation when all previous conversations are resolved' do
+ line_bot = double
+ line_user_profile = double
+ allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
+ allow(line_bot).to receive(:get_profile).and_return(line_user_profile)
+ allow(line_user_profile).to receive(:body).and_return(
+ {
+ 'displayName': 'LINE Test',
+ 'userId': 'U4af4980629',
+ 'pictureUrl': 'https://test.com'
+ }.to_json
+ )
+
+ # Create a contact and a resolved conversation
+ described_class.new(inbox: line_channel.inbox, params: params).perform
+
+ # Mark the conversation as resolved
+ conversation = line_channel.inbox.conversations.last
+ conversation.update(status: :resolved)
+
+ # Send a new message
+ new_params = params.deep_dup
+ new_params[:events][0][:message][:id] = '325709'
+ new_params[:events][0][:message][:text] = 'Second message'
+
+ described_class.new(inbox: line_channel.inbox, params: new_params).perform
+
+ # Should create a new conversation
+ expect(line_channel.inbox.conversations.count).to eq(2)
+ expect(line_channel.inbox.conversations.last.messages.first.content).to eq('Second message')
+ end
+
+ it 'uses the existing conversation when there is an unresolved conversation' do
+ line_bot = double
+ line_user_profile = double
+ allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
+ allow(line_bot).to receive(:get_profile).and_return(line_user_profile)
+ allow(line_user_profile).to receive(:body).and_return(
+ {
+ 'displayName': 'LINE Test',
+ 'userId': 'U4af4980629',
+ 'pictureUrl': 'https://test.com'
+ }.to_json
+ )
+
+ # Create a contact and an unresolved conversation
+ described_class.new(inbox: line_channel.inbox, params: params).perform
+
+ # Send a new message
+ new_params = params.deep_dup
+ new_params[:events][0][:message][:id] = '325709'
+ new_params[:events][0][:message][:text] = 'Second message'
+
+ described_class.new(inbox: line_channel.inbox, params: new_params).perform
+
+ # Should use the same conversation
+ expect(line_channel.inbox.conversations.count).to eq(1)
+ expect(line_channel.inbox.conversations.last.messages.count).to eq(2)
+ expect(line_channel.inbox.conversations.last.messages.last.content).to eq('Second message')
+ end
+ end
+
+ context 'when lock_to_single_conversation is true' do
+ before do
+ line_channel.inbox.update(lock_to_single_conversation: true)
+ end
+
+ it 'uses the existing conversation even when it is resolved' do
+ line_bot = double
+ line_user_profile = double
+ allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
+ allow(line_bot).to receive(:get_profile).and_return(line_user_profile)
+ allow(line_user_profile).to receive(:body).and_return(
+ {
+ 'displayName': 'LINE Test',
+ 'userId': 'U4af4980629',
+ 'pictureUrl': 'https://test.com'
+ }.to_json
+ )
+
+ # Create a contact and a resolved conversation
+ described_class.new(inbox: line_channel.inbox, params: params).perform
+
+ # Mark the conversation as resolved
+ conversation = line_channel.inbox.conversations.last
+ conversation.update(status: :resolved)
+
+ # Send a new message
+ new_params = params.deep_dup
+ new_params[:events][0][:message][:id] = '325709'
+ new_params[:events][0][:message][:text] = 'Second message'
+
+ described_class.new(inbox: line_channel.inbox, params: new_params).perform
+
+ # Should use the same conversation
+ expect(line_channel.inbox.conversations.count).to eq(1)
+ expect(line_channel.inbox.conversations.last.messages.count).to eq(2)
+ expect(line_channel.inbox.conversations.last.messages.last.content).to eq('Second message')
+ end
+ end
end
end
diff --git a/spec/services/messages/new_message_notification_service_spec.rb b/spec/services/messages/new_message_notification_service_spec.rb
index 51da1ad5a..c8e2b7b98 100644
--- a/spec/services/messages/new_message_notification_service_spec.rb
+++ b/spec/services/messages/new_message_notification_service_spec.rb
@@ -2,11 +2,17 @@ require 'rails_helper'
describe Messages::NewMessageNotificationService do
context 'when message is not notifiable' do
- it 'will not create any notifications' do
+ it 'will not create any notifications for activity messages' do
message = build(:message, message_type: :activity)
expect(NotificationBuilder).not_to receive(:new)
described_class.new(message: message).perform
end
+
+ it 'will not create any notifications for private messages' do
+ message = build(:message, message_type: :outgoing, private: true)
+ expect(NotificationBuilder).not_to receive(:new)
+ described_class.new(message: message).perform
+ end
end
context 'when message is notifiable' do
diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb
index 528161afe..b81b18756 100644
--- a/spec/services/telegram/incoming_message_service_spec.rb
+++ b/spec/services/telegram/incoming_message_service_spec.rb
@@ -410,6 +410,94 @@ describe Telegram::IncomingMessageService do
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('contact')
end
end
+
+ context 'when lock_to_single_conversation is false' do
+ before do
+ telegram_channel.inbox.update(lock_to_single_conversation: false)
+ end
+
+ it 'creates a new conversation when all previous conversations are resolved' do
+ # Create a contact and a resolved conversation
+ params = {
+ 'update_id' => 2_342_342_343_242,
+ 'message' => { 'text' => 'first message' }.merge(message_params)
+ }.with_indifferent_access
+
+ described_class.new(inbox: telegram_channel.inbox, params: params).perform
+
+ # Mark the conversation as resolved
+ conversation = telegram_channel.inbox.conversations.last
+ conversation.update(status: :resolved)
+
+ # Send a new message
+ new_params = {
+ 'update_id' => 2_342_342_343_243,
+ 'message' => { 'text' => 'second message' }.merge(message_params)
+ }.with_indifferent_access
+
+ described_class.new(inbox: telegram_channel.inbox, params: new_params).perform
+
+ # Should create a new conversation
+ expect(telegram_channel.inbox.conversations.count).to eq(2)
+ expect(telegram_channel.inbox.conversations.last.messages.first.content).to eq('second message')
+ end
+
+ it 'uses the existing conversation when there is an unresolved conversation' do
+ # Create a contact and an unresolved conversation
+ params = {
+ 'update_id' => 2_342_342_343_242,
+ 'message' => { 'text' => 'first message' }.merge(message_params)
+ }.with_indifferent_access
+
+ described_class.new(inbox: telegram_channel.inbox, params: params).perform
+
+ # Send a new message
+ new_params = {
+ 'update_id' => 2_342_342_343_243,
+ 'message' => { 'text' => 'second message' }.merge(message_params)
+ }.with_indifferent_access
+
+ described_class.new(inbox: telegram_channel.inbox, params: new_params).perform
+
+ # Should use the same conversation
+ expect(telegram_channel.inbox.conversations.count).to eq(1)
+ expect(telegram_channel.inbox.conversations.last.messages.count).to eq(2)
+ expect(telegram_channel.inbox.conversations.last.messages.last.content).to eq('second message')
+ end
+ end
+
+ context 'when lock_to_single_conversation is true' do
+ before do
+ telegram_channel.inbox.update(lock_to_single_conversation: true)
+ end
+
+ it 'uses the existing conversation even when it is resolved' do
+ # Create a contact and a resolved conversation
+ params = {
+ 'update_id' => 2_342_342_343_242,
+ 'message' => { 'text' => 'first message' }.merge(message_params)
+ }.with_indifferent_access
+
+ described_class.new(inbox: telegram_channel.inbox, params: params).perform
+
+ # Mark the conversation as resolved
+ conversation = telegram_channel.inbox.conversations.last
+ conversation.update(status: :resolved)
+
+ # Send a new message
+ new_params = {
+ 'update_id' => 2_342_342_343_243,
+ 'message' => { 'text' => 'second message' }.merge(message_params)
+ }.with_indifferent_access
+
+ described_class.new(inbox: telegram_channel.inbox, params: new_params).perform
+
+ # Should use the same conversation
+ expect(telegram_channel.inbox.conversations.count).to eq(1)
+ expect(telegram_channel.inbox.conversations.last.messages.count).to eq(2)
+ expect(telegram_channel.inbox.conversations.last.messages.last.content).to eq('second message')
+ end
+ end
end
context 'when lock to single conversation is enabled' do
diff --git a/spec/services/tiktok/message_service_spec.rb b/spec/services/tiktok/message_service_spec.rb
index fbef64336..ee7b113ac 100644
--- a/spec/services/tiktok/message_service_spec.rb
+++ b/spec/services/tiktok/message_service_spec.rb
@@ -6,8 +6,29 @@ RSpec.describe Tiktok::MessageService do
let(:inbox) { channel.inbox }
let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
+ let(:text_content) do
+ {
+ type: 'text',
+ message_id: 'tt-msg-lock',
+ timestamp: 1_700_000_000_000,
+ conversation_id: 'tt-conv-1',
+ text: { body: 'Hello from TikTok' },
+ from: 'Alice',
+ from_user: { id: 'user-1' },
+ to: 'Biz',
+ to_user: { id: 'biz-123' }
+ }.deep_symbolize_keys
+ end
describe '#perform' do
+ subject(:perform_text_message) do
+ service = described_class.new(channel: channel, content: current_content)
+ allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
+ service.perform
+ end
+
+ let(:current_content) { text_content }
+
it 'creates an incoming text message' do
content = {
type: 'text',
@@ -113,5 +134,31 @@ RSpec.describe Tiktok::MessageService do
ensure
tempfile.close!
end
+
+ context 'when lock_to_single_conversation is enabled' do
+ it 'reuses the last resolved conversation' do
+ inbox.update!(lock_to_single_conversation: true)
+ resolved_conversation = create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :resolved)
+
+ perform_text_message
+
+ expect(inbox.conversations.count).to eq(1)
+ expect(resolved_conversation.reload.messages.last.content).to eq('Hello from TikTok')
+ end
+ end
+
+ context 'when lock_to_single_conversation is disabled' do
+ let(:current_content) { text_content.merge(message_id: 'tt-msg-lock-2') }
+
+ it 'creates a new conversation if the previous one is resolved' do
+ inbox.update!(lock_to_single_conversation: false)
+ create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :resolved)
+
+ perform_text_message
+
+ expect(inbox.conversations.count).to eq(2)
+ expect(inbox.conversations.last.messages.last.content).to eq('Hello from TikTok')
+ end
+ end
end
end
diff --git a/spec/services/tiktok/read_status_service_spec.rb b/spec/services/tiktok/read_status_service_spec.rb
index f0ec116ff..3c44cc738 100644
--- a/spec/services/tiktok/read_status_service_spec.rb
+++ b/spec/services/tiktok/read_status_service_spec.rb
@@ -31,5 +31,31 @@ RSpec.describe Tiktok::ReadStatusService do
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, kind_of(Time))
end
+
+ it 'updates the latest active conversation when lock_to_single_conversation is disabled' do
+ allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
+
+ inbox.update!(lock_to_single_conversation: false)
+ conversation.update!(status: :resolved)
+ active_conversation = create(
+ :conversation,
+ account: account,
+ inbox: inbox,
+ contact: contact,
+ contact_inbox: contact_inbox,
+ status: :open,
+ additional_attributes: { conversation_id: 'tt-conv-1' }
+ )
+
+ content = {
+ conversation_id: 'tt-conv-1',
+ read: { last_read_timestamp: 1_700_000_000_000 },
+ from_user: { id: 'user-1' }
+ }.deep_symbolize_keys
+
+ described_class.new(channel: channel, content: content).perform
+
+ expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(active_conversation.id, kind_of(Time))
+ end
end
end
diff --git a/spec/services/whatsapp/csat_template_service_spec.rb b/spec/services/whatsapp/csat_template_service_spec.rb
index f21d87227..d7124529d 100644
--- a/spec/services/whatsapp/csat_template_service_spec.rb
+++ b/spec/services/whatsapp/csat_template_service_spec.rb
@@ -119,7 +119,7 @@ RSpec.describe Whatsapp::CsatTemplateService do
expect(result).to eq({
name: expected_template_name,
language: 'en',
- category: 'MARKETING',
+ category: 'UTILITY',
components: [
{
type: 'BODY',
@@ -169,7 +169,7 @@ RSpec.describe Whatsapp::CsatTemplateService do
expected_body = {
name: expected_template_name,
language: 'en',
- category: 'MARKETING',
+ category: 'UTILITY',
components: [
{
type: 'BODY',