Merge branch 'develop' into perf/conversation_count

This commit is contained in:
Tanmay Deep Sharma
2026-02-24 18:26:06 +05:30
committed by GitHub
33 changed files with 845 additions and 76 deletions
@@ -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)
+17 -21
View File
@@ -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)
+6
View File
@@ -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();
@@ -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,
},
},
});
};
@@ -458,6 +458,9 @@
"INSTAGRAM": {
"PLACEHOLDER": "Add Instagram"
},
"TELEGRAM": {
"PLACEHOLDER": "Add Telegram"
},
"TIKTOK": {
"PLACEHOLDER": "Add TikTok"
},
@@ -890,6 +890,20 @@
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"UTILITY_ANALYZER": {
"ACTION": "Check utility fit",
"HELPER_NOTE": "Check this message before submission to improve Utility fit. The system creates a dedicated CSAT template with buttons for reporting and submits it as Utility; Meta may still reclassify it as Marketing based on content.",
"RESULT_LABEL": "Meta category prediction",
"GUIDANCE_NOTE": "This is a guidance check, not a guarantee of Meta approval.",
"SUGGESTION_LABEL": "Suggested utility-safe rewrite",
"APPLY": "Use this rewrite",
"ERROR_MESSAGE": "Couldn't analyze the message. Please try again.",
"CLASSIFICATION": {
"LIKELY_UTILITY": "Likely Utility",
"LIKELY_MARKETING": "Likely Marketing",
"UNCLEAR": "Needs clarification"
}
},
"SURVEY_RULE": {
"LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -901,7 +915,7 @@
"SELECT_PLACEHOLDER": "select labels"
},
"NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"WHATSAPP_NOTE": "Note: When you save, the system creates a dedicated CSAT template in WhatsApp (used to capture rating and feedback in reports) and submits it as Utility for approval. Meta may still classify it as Marketing based on content. After approval, surveys are sent only once per conversation as per the survey rule.",
"API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -58,12 +58,14 @@ export default {
twitter: '',
linkedin: '',
github: '',
telegram: '',
},
socialProfileKeys: [
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
{ key: 'github', prefixURL: 'https://github.com/' },
{ key: 'telegram', prefixURL: 'https://t.me/' },
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
],
};
@@ -175,12 +177,14 @@ export default {
const {
social_profiles: socialProfiles = {},
screen_name: twitterScreenName,
social_telegram_user_name: telegramUserName,
} = additionalAttributes;
this.socialProfileUserNames = {
twitter: socialProfiles.twitter || twitterScreenName || '',
facebook: socialProfiles.facebook || '',
linkedin: socialProfiles.linkedin || '',
github: socialProfiles.github || '',
telegram: socialProfiles.telegram || telegramUserName || '',
instagram: socialProfiles.instagram || '',
tiktok: socialProfiles.tiktok || '',
};
@@ -81,10 +81,14 @@ export default {
screen_name: twitterScreenName,
social_telegram_user_name: telegramUsername,
} = this.additionalAttributes;
const telegram = socialProfiles?.telegram || telegramUsername || '';
const twitter = socialProfiles?.twitter || twitterScreenName || '';
return {
twitter: twitterScreenName,
telegram: telegramUsername,
...(socialProfiles || {}),
twitter,
telegram,
};
},
// Delete Modal
@@ -147,7 +147,9 @@ export default {
await this.$store.dispatch('inboxes/updateInboxSMTP', payload);
useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE'));
useAlert(
error.message || this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE')
);
}
},
},
@@ -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"
/>
</WithLabel>
<div v-if="showUtilityAnalyzer" class="flex flex-col gap-2">
<NextButton
sm
slate
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ACTION')"
:is-loading="utilityAnalysisLoading"
:disabled="!state.message?.trim()"
@click="analyzeTemplateUtility"
/>
<p class="text-xs text-n-slate-11">
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.HELPER_NOTE') }}
</p>
</div>
<div
v-if="utilityAnalysisResult"
class="flex flex-col gap-3 p-3 rounded-xl outline outline-1 outline-n-weak bg-n-alpha-1"
>
<div class="flex gap-2 items-center">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.RESULT_LABEL') }}
</span>
<span
class="px-2 py-0.5 text-xs font-medium rounded-full"
:class="
getUtilityClassificationClass(
utilityAnalysisResult.classification
)
"
>
{{
getUtilityClassificationLabel(
utilityAnalysisResult.classification
)
}}
</span>
</div>
<p class="text-xs text-n-slate-11">
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.GUIDANCE_NOTE') }}
</p>
<div
v-if="
utilityAnalysisResult.optimized_message &&
utilityAnalysisResult.classification !== 'LIKELY_UTILITY'
"
class="flex flex-col gap-2"
>
<p class="text-xs font-medium text-n-slate-12">
{{
$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.SUGGESTION_LABEL')
}}
</p>
<p class="text-sm text-n-slate-12">
{{ utilityAnalysisResult.optimized_message }}
</p>
<NextButton
sm
faded
slate
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.APPLY')"
@click="applyUtilitySuggestion"
/>
</div>
</div>
<Input
v-model="state.templateButtonText"
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
@@ -360,6 +360,13 @@ export const actions = {
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
return response.data;
},
analyzeCSATTemplateUtility: async (_, { inboxId, template }) => {
const response = await InboxesAPI.analyzeCSATTemplateUtility(
inboxId,
template
);
return response.data;
},
};
export const mutations = {
@@ -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')
@@ -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
@@ -14,7 +14,7 @@ module MessageFilterHelpers
end
def notifiable?
incoming? || outgoing?
(incoming? || outgoing?) && !private?
end
def conversation_transcriptable?
@@ -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)
+2 -10
View File
@@ -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
@@ -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]
{
@@ -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
@@ -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 %<subject>s has been %<status>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
@@ -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)
+8
View File
@@ -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:
+3 -1
View File
@@ -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
@@ -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
@@ -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`.
@@ -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
@@ -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)
@@ -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
@@ -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
+19 -3
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',