From 6be95e79f8964d922d7de3d7ceb76874e6405ac4 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 24 Feb 2026 15:11:04 +0400 Subject: [PATCH 01/24] feat(csat): Add WhatsApp utility template analyzer with rewrite guidance (#13575) CSAT templates for WhatsApp are submitted as Utility, but Meta may reclassify them as Marketing based on content, which can significantly increase messaging costs. This PR introduces a Captain-powered CSAT template analyzer for WhatsApp/Twilio WhatsApp that predicts utility fit, explains likely risks, and suggests safer rewrites before submission. The flow is manual (button-triggered), Captain-gated, and applies rewrites only on explicit user action. It also updates UX copy to clearly set expectations: the system submits as Utility, Meta makes the final categorization decision. Fixes https://linear.app/chatwoot/issue/CW-6424/ai-powered-whatsapp-template-classifier-for-csat-submissions https://github.com/user-attachments/assets/8fd1d6db-2f91-447c-9771-3de271b16fd9 --- .../inbox_csat_templates_controller.rb | 24 +++ app/javascript/dashboard/api/inboxes.js | 6 + .../dashboard/i18n/locale/en/inboxMgmt.json | 16 +- .../settingsPage/CustomerSatisfactionPage.vue | 152 +++++++++++++++++- .../dashboard/store/modules/inboxes.js | 7 + .../csat_template_management_service.rb | 2 + .../csat_template_utility_analysis_service.rb | 86 ++++++++++ app/services/csat_template_utility_rubric.rb | 125 ++++++++++++++ .../whatsapp/csat_template_service.rb | 2 +- config/routes.rb | 4 +- lib/captain/csat_utility_analysis_service.rb | 66 ++++++++ .../csat_utility_analysis.liquid | 27 ++++ .../inbox_csat_templates_controller_spec.rb | 91 +++++++++++ .../csat_utility_analysis_service_spec.rb | 24 +++ ..._template_utility_analysis_service_spec.rb | 81 ++++++++++ .../whatsapp/csat_template_service_spec.rb | 4 +- 16 files changed, 711 insertions(+), 6 deletions(-) create mode 100644 app/services/csat_template_utility_analysis_service.rb create mode 100644 app/services/csat_template_utility_rubric.rb create mode 100644 lib/captain/csat_utility_analysis_service.rb create mode 100644 lib/integrations/openai/openai_prompts/csat_utility_analysis.liquid create mode 100644 spec/lib/captain/csat_utility_analysis_service_spec.rb create mode 100644 spec/services/csat_template_utility_analysis_service_spec.rb 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/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/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index f4f62764b..aaa8fd5bf 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -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." 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/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/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/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/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/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/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/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/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/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', From 7cec4ebaaefe0b2bda4efe72aa1f8b36ced33364 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:37:41 +0530 Subject: [PATCH 02/24] feat: support multimodal user messages in captain v2 (#13581) Extract and pass image attachments from the latest user message to the runner, excluding the last user message from the context for processing. Fixes #13588 # Pull Request Template ## Description Adds image support to captain v2 ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. specs and local testing image langfuse also shows media correctly with the instrumentation code: image ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Shivam Mishra Co-authored-by: Claude Opus 4.6 --- Gemfile | 2 +- Gemfile.lock | 4 +- .../captain/assistant/agent_runner_service.rb | 85 ++++++------ .../assistant/runner_callbacks_helper.rb | 53 +++++++ .../captain/assistant/trace_payload_helper.rb | 51 +++++++ .../assistant/agent_runner_service_spec.rb | 131 ++++++++++++++++-- 6 files changed, 264 insertions(+), 62 deletions(-) create mode 100644 enterprise/app/services/captain/assistant/runner_callbacks_helper.rb create mode 100644 enterprise/app/services/captain/assistant/trace_payload_helper.rb diff --git a/Gemfile b/Gemfile index e6c5a5250..0636b7b2b 100644 --- a/Gemfile +++ b/Gemfile @@ -191,7 +191,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 aad0438cf..7a7316e3c 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) @@ -1024,7 +1024,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/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/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) } From 76f129efaf1ea6a21b9e191abf89a7b11ba8605d Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 24 Feb 2026 20:13:58 +0400 Subject: [PATCH 03/24] feat(tiktok): Enable outgoing image attachments (#13620) - Enabled the attachment button for TikTok conversations in the reply box - Auto-split messages when both text and an image are composed together, since the TikTok API rejects mixed text+media in a single message. Fixes https://linear.app/chatwoot/issue/CW-6528/enable-outgoing-image-attachments --- .../widgets/WootWriter/ReplyBottomPanel.vue | 2 +- .../widgets/conversation/ReplyBox.vue | 26 ++++++++++++------- package.json | 2 +- pnpm-lock.yaml | 10 +++---- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index 241e65505..bc0001238 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -189,7 +189,7 @@ export default { }, showAudioRecorderButton() { if (this.isEditorDisabled) return false; - if (this.isALineChannel) { + if (this.isALineChannel || this.isATiktokChannel) { return false; } // Disable audio recorder for safari browser as recording is not supported diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 78a2481e9..e55f13b7c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -279,7 +279,8 @@ export default { this.isASmsInbox || this.isATelegramChannel || this.isALineChannel || - this.isAnInstagramChannel + this.isAnInstagramChannel || + this.isATiktokChannel ); }, replyButtonLabel() { @@ -751,11 +752,13 @@ export default { this.isATwilioWhatsAppChannel || this.isAWhatsAppCloudChannel || this.is360DialogWhatsAppChannel; - // When users send messages containing both text and attachments on Instagram, Instagram treats them as separate messages. - // Although Chatwoot combines these into a single message, Instagram sends separate echo events for each component. - // This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages. + // Instagram and TikTok do not support sending text and attachments in the same message. + // For Instagram, combining them causes duplicate messages due to separate echo events per component. + // For TikTok, the API rejects messages that mix text and media. + // To handle both cases, text and attachments are always sent as separate messages. const isOnInstagram = this.isAnInstagramChannel; - if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) { + const isOnTiktok = this.isATiktokChannel; + if ((isOnWhatsApp || isOnInstagram || isOnTiktok) && !this.isPrivate) { this.sendMessageAsMultipleMessages( this.message, copilotAcceptedMessage @@ -1069,7 +1072,8 @@ export default { const multipleMessagePayload = []; if (this.attachedFiles && this.attachedFiles.length) { - let caption = this.isAnInstagramChannel ? '' : message; + let caption = + this.isAnInstagramChannel || this.isATiktokChannel ? '' : message; this.attachedFiles.forEach(attachment => { const attachedFile = this.globalConfig.directUploadsEnabled ? attachment.blobSignedId @@ -1091,11 +1095,13 @@ export default { const hasNoAttachments = !this.attachedFiles || !this.attachedFiles.length; - // For Instagram, we need a separate text message - // For WhatsApp, we only need a text message if there are no attachments + // For Instagram and TikTok, text must always be sent as a separate message (no captions on attachments). + // For WhatsApp, we only need a text message if there are no attachments. if ( - (this.isAnInstagramChannel && this.message) || - (!this.isAnInstagramChannel && hasNoAttachments) + ((this.isAnInstagramChannel || this.isATiktokChannel) && + this.message) || + (!(this.isAnInstagramChannel || this.isATiktokChannel) && + hasNoAttachments) ) { let messagePayload = { conversationId: this.currentChat.id, diff --git a/package.json b/package.json index 37b1307cd..a850bdf09 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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e880b4aaf..4d5f81119 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 @@ -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': @@ -5010,7 +5010,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 From 55f625731344c0c9dea9fb89ef93008b1c226974 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 24 Feb 2026 20:29:53 -0800 Subject: [PATCH 04/24] chore(hub): clean up legacy Captain hub flow (#13640) ## Summary This PR cleans up legacy Hub/Captain integration paths and simplifies hub URL behavior coverage in tests. ## Changes - remove legacy Captain account endpoint flow from `ChatwootHub` - remove obsolete spec coverage tied to that retired flow - keep hub URL handling centralized in `base_url` with enterprise overlay precedence - simplify hub URL specs to assert explicit static URL expectations where applicable ## Reproduce Run the focused hub specs from this branch: - `bundle exec rspec spec/lib/chatwoot_hub_spec.rb spec/enterprise/lib/chatwoot_hub_spec.rb` ## Testing Validated locally with: - `bundle exec rspec spec/lib/chatwoot_hub_spec.rb spec/enterprise/lib/chatwoot_hub_spec.rb` - `bundle exec rubocop lib/chatwoot_hub.rb spec/lib/chatwoot_hub_spec.rb enterprise/lib/enterprise/chatwoot_hub.rb spec/enterprise/lib/chatwoot_hub_spec.rb` --- enterprise/lib/enterprise/chatwoot_hub.rb | 9 ++++ lib/chatwoot_hub.rb | 55 +++++++++++++---------- spec/enterprise/lib/chatwoot_hub_spec.rb | 21 +++++++++ spec/lib/chatwoot_hub_spec.rb | 37 +++++---------- 4 files changed, 74 insertions(+), 48 deletions(-) create mode 100644 enterprise/lib/enterprise/chatwoot_hub.rb create mode 100644 spec/enterprise/lib/chatwoot_hub_spec.rb diff --git a/enterprise/lib/enterprise/chatwoot_hub.rb b/enterprise/lib/enterprise/chatwoot_hub.rb new file mode 100644 index 000000000..a9d572d2d --- /dev/null +++ b/enterprise/lib/enterprise/chatwoot_hub.rb @@ -0,0 +1,9 @@ +module Enterprise::ChatwootHub + ENTERPRISE_BASE_URL = 'https://hub.2.chatwoot.com'.freeze + + def base_url + return ENV.fetch('CHATWOOT_HUB_URL', ENTERPRISE_BASE_URL) if Rails.env.development? + + ENTERPRISE_BASE_URL + end +end diff --git a/lib/chatwoot_hub.rb b/lib/chatwoot_hub.rb index c18fb299b..be5a07e05 100644 --- a/lib/chatwoot_hub.rb +++ b/lib/chatwoot_hub.rb @@ -1,12 +1,30 @@ # TODO: lets use HTTParty instead of RestClient class ChatwootHub - BASE_URL = ENV.fetch('CHATWOOT_HUB_URL', 'https://hub.2.chatwoot.com') - PING_URL = "#{BASE_URL}/ping".freeze - REGISTRATION_URL = "#{BASE_URL}/instances".freeze - PUSH_NOTIFICATION_URL = "#{BASE_URL}/send_push".freeze - EVENTS_URL = "#{BASE_URL}/events".freeze - BILLING_URL = "#{BASE_URL}/billing".freeze - CAPTAIN_ACCOUNTS_URL = "#{BASE_URL}/instance_captain_accounts".freeze + DEFAULT_BASE_URL = 'https://hub.2.chatwoot.com'.freeze + + def self.base_url + DEFAULT_BASE_URL + end + + def self.ping_url + "#{base_url}/ping" + end + + def self.registration_url + "#{base_url}/instances" + end + + def self.push_notification_url + "#{base_url}/send_push" + end + + def self.events_url + "#{base_url}/events" + end + + def self.billing_base_url + "#{base_url}/billing" + end def self.installation_identifier identifier = InstallationConfig.find_by(name: 'INSTALLATION_IDENTIFIER')&.value @@ -15,7 +33,7 @@ class ChatwootHub end def self.billing_url - "#{BILLING_URL}?installation_identifier=#{installation_identifier}" + "#{billing_base_url}?installation_identifier=#{installation_identifier}" end def self.pricing_plan @@ -68,7 +86,7 @@ class ChatwootHub begin info = instance_config info = info.merge(instance_metrics) unless ENV['DISABLE_TELEMETRY'] - response = RestClient.post(PING_URL, info.to_json, { content_type: :json, accept: :json }) + response = RestClient.post(ping_url, info.to_json, { content_type: :json, accept: :json }) parsed_response = JSON.parse(response) rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e Rails.logger.error "Exception: #{e.message}" @@ -80,7 +98,7 @@ class ChatwootHub def self.register_instance(company_name, owner_name, owner_email) info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true } - RestClient.post(REGISTRATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) + RestClient.post(registration_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e Rails.logger.error "Exception: #{e.message}" rescue StandardError => e @@ -89,32 +107,23 @@ class ChatwootHub def self.send_push(fcm_options) info = { fcm_options: fcm_options } - RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) + RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e Rails.logger.error "Exception: #{e.message}" rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception end - def self.get_captain_settings(account) - info = { - installation_identifier: installation_identifier, - chatwoot_account_id: account.id, - account_name: account.name - } - HTTParty.post(CAPTAIN_ACCOUNTS_URL, - body: info.to_json, - headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' }) - end - def self.emit_event(event_name, event_data) return if ENV['DISABLE_TELEMETRY'] info = { event_name: event_name, event_data: event_data } - RestClient.post(EVENTS_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) + RestClient.post(events_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json }) rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e Rails.logger.error "Exception: #{e.message}" rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception end end + +ChatwootHub.singleton_class.prepend_mod_with('ChatwootHub') diff --git a/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/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 From 172ff87b5b3ca6bbc27f5805f8cbf76fd1265b31 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:29:02 +0530 Subject: [PATCH 05/24] feat: Replace `vue-virtual-scroller` with `virtua` for chat list virtualization (#13642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description This PR replaces `vue-virtual-scroller` with [`virtua`](https://github.com/inokawa/virtua/#benchmark) for the conversation list virtualization. ### Changes - Replace `vue-virtual-scroller` (`DynamicScroller`/`DynamicScrollerItem`) with `virtua`'s `Virtualizer` component - Remove `IntersectionObserver`-based infinite scroll in favor of `Virtualizer`'s `@scroll` event with offset-based bottom detection - Remove `useEventListener` scroll binding and `intersectionObserverOptions` computed - Simplify item rendering — no more `DynamicScrollerItem` wrapper or `size-dependencies` tracking; `virtua` measures items automatically ## Type of change - [x] New feature (non-breaking change which adds functionality) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../dashboard/components/ChatList.vue | 138 ++++++------------ package.json | 2 +- pnpm-lock.yaml | 56 ++++--- 3 files changed, 73 insertions(+), 123 deletions(-) diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 3b2a66929..a060bcc4f 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -17,10 +17,7 @@ import { useFunctionGetter, } from 'dashboard/composables/store.js'; -// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate -// from the current one. But we should consider using tanstack virtual in the future -// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable -import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; +import { Virtualizer } from 'virtua/vue'; import ChatListHeader from './ChatListHeader.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import ConversationFilter from 'next/filter/ConversationFilter.vue'; @@ -29,9 +26,9 @@ import ChatTypeTabs from './widgets/ChatTypeTabs.vue'; import ConversationItem from './ConversationItem.vue'; import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue'; import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue'; -import IntersectionObserver from './IntersectionObserver.vue'; import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; +import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue'; import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue'; import { useUISettings } from 'dashboard/composables/useUISettings'; @@ -46,7 +43,6 @@ import { useSnakeCase, } from 'dashboard/composables/useTransformKeys'; import { useEmitter } from 'dashboard/composables/emitter'; -import { useEventListener } from '@vueuse/core'; import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes'; import { emitter } from 'shared/helpers/mitt'; @@ -70,8 +66,6 @@ import { matchesFilters } from '../store/modules/conversations/helpers/filterHel import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events'; import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js'; -import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; - const props = defineProps({ conversationInbox: { type: [String, Number], default: 0 }, teamId: { type: [String, Number], default: 0 }, @@ -91,9 +85,9 @@ const store = useStore(); const resolveAttributesModalRef = ref(null); const conversationListRef = ref(null); -const conversationDynamicScroller = ref(null); +const virtualListRef = ref(null); -provide('contextMenuElementTarget', conversationDynamicScroller); +provide('contextMenuElementTarget', virtualListRef); const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME); const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN); @@ -161,12 +155,6 @@ const { const { checkMissingAttributes } = useConversationRequiredAttributes(); // computed -const intersectionObserverOptions = computed(() => { - return { - root: conversationListRef.value, - rootMargin: '100px 0px 100px 0px', - }; -}); const hasAppliedFilters = computed(() => { return appliedFilters.value.length !== 0; @@ -384,18 +372,6 @@ function setFiltersFromUISettings() { function emitConversationLoaded() { emit('conversationLoad'); - // [VITE] removing this since the library has changed - // nextTick(() => { - // // Addressing a known issue in the virtual list library where dynamically added items - // // might not render correctly. This workaround involves a slight manual adjustment - // // to the scroll position, triggering the list to refresh its rendering. - // const virtualList = conversationListRef.value; - // const scrollToOffset = virtualList?.scrollToOffset; - // const currentOffset = virtualList?.getOffset() || 0; - // if (scrollToOffset) { - // scrollToOffset(currentOffset + 1); - // } - // }); } function fetchFilteredConversations(payload) { @@ -607,16 +583,13 @@ function loadMoreConversations() { } } -// Add a method to handle scroll events -function handleScroll() { - const scroller = conversationDynamicScroller.value; - if (scroller && scroller.hasScrollbar) { - const { scrollTop, scrollHeight, clientHeight } = scroller.$el; - if (scrollHeight - (scrollTop + clientHeight) < 100) { - loadMoreConversations(); - } - } -} +// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll. +// If the list doesn’t fill the viewport, loading can stall. +// IntersectionObserver triggers as soon as the sentinel is visible. +const intersectionObserverOptions = computed(() => ({ + root: conversationListRef.value, + rootMargin: '100px 0px 100px 0px', +})); function updateAssigneeTab(selectedTab) { if (activeAssigneeTab.value !== selectedTab) { @@ -822,8 +795,6 @@ useEmitter('fetch_conversation_stats', () => { store.dispatch('conversationStats/get', conversationFilters.value); }); -useEventListener(conversationDynamicScroller, 'scroll', handleScroll); - onMounted(() => { store.dispatch('setChatListFilters', conversationFilters.value); setFiltersFromUISettings(); @@ -977,61 +948,42 @@ watch(conversationFilters, (newVal, oldVal) => { />
- - - - + + +
+ +
+

+ {{ $t('CHAT_LIST.EOF') }} +

+
=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==} @@ -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): From a44cb2c738bef69b85824e32b05acefff5e936a5 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 25 Feb 2026 01:56:51 -0800 Subject: [PATCH 06/24] feat(inbox): Enable conversation continuity for social channels (#11079) ## Summary This PR enables and surfaces **conversation workflow** for social-style channels that should support either: - `Create new conversations` after resolve, or - `Reopen same conversation` ## What is included - Adds the conversation workflow setting UI as card-based options in Inbox Settings. - Expands channel availability in settings to include channels like: - Telegram - TikTok - Instagram - Line - WhatsApp - Facebook - Updates conversation selection behavior for Line incoming messages to respect the workflow (reopen vs create-new-after-resolved). - Updates TikTok conversation selection behavior to respect the workflow (reopen vs create-new-after-resolved). - Keeps email behavior unchanged (always starts a new thread). Fixes: https://github.com/chatwoot/chatwoot/issues/8426 ## Screenshot pr11079-workflow-sender-clear-tight ## How To Reproduce 1. Open `Settings -> Inboxes -> -> Settings`. 2. Verify **Conversation workflow** is visible with the two card options. 3. Toggle between both options and save. 4. For Line and TikTok, verify resolved-conversation behavior follows the selected workflow. ## Testing - `RAILS_ENV=test bundle exec rspec spec/builders/messages/instagram/message_builder_spec.rb:213 spec/builders/messages/instagram/message_builder_spec.rb:255 spec/builders/messages/instagram/messenger/message_builder_spec.rb:228 spec/builders/messages/instagram/messenger/message_builder_spec.rb:293 spec/services/tiktok/message_service_spec.rb` - Result: `16 examples, 0 failures` ## Follow-up - Migrate Website Live Chat workflow settings into this same conversation-workflow settings model. - Add Voice channel support for this workflow setting. --------- Co-authored-by: Muhsin Keloth Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin --- .../story => radioCard}/RadioCard.story.vue | 2 +- .../components => radioCard}/RadioCard.vue | 0 .../dashboard/i18n/locale/en/inboxMgmt.json | 10 +- .../components/AgentAssignmentPolicyForm.vue | 2 +- .../dashboard/settings/inbox/Settings.vue | 36 +++--- .../LockToSingleConversationPreview.vue | 40 +++++++ .../components/SenderNameExamplePreview.vue | 2 +- app/services/line/incoming_message_service.rb | 7 +- app/services/tiktok/message_service.rb | 7 +- app/services/tiktok/messaging_helpers.rb | 10 +- .../tiktok/authorizations_controller_spec.rb | 34 +++--- .../line/incoming_message_service_spec.rb | 106 ++++++++++++++++++ .../telegram/incoming_message_service_spec.rb | 88 +++++++++++++++ spec/services/tiktok/message_service_spec.rb | 47 ++++++++ .../tiktok/read_status_service_spec.rb | 26 +++++ 15 files changed, 378 insertions(+), 39 deletions(-) rename app/javascript/dashboard/components-next/{AssignmentPolicy/components/story => radioCard}/RadioCard.story.vue (97%) rename app/javascript/dashboard/components-next/{AssignmentPolicy/components => radioCard}/RadioCard.vue (100%) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/components/LockToSingleConversationPreview.vue 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/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/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/services/line/incoming_message_service_spec.rb b/spec/services/line/incoming_message_service_spec.rb index a7805ce9b..997777a43 100644 --- a/spec/services/line/incoming_message_service_spec.rb +++ b/spec/services/line/incoming_message_service_spec.rb @@ -405,5 +405,111 @@ describe Line::IncomingMessageService do expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('contacts.csv') end end + + context 'when lock_to_single_conversation is false' do + before do + line_channel.inbox.update(lock_to_single_conversation: false) + end + + it 'creates a new conversation when all previous conversations are resolved' do + line_bot = double + line_user_profile = double + allow(Line::Bot::Client).to receive(:new).and_return(line_bot) + allow(line_bot).to receive(:get_profile).and_return(line_user_profile) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + + # Create a contact and a resolved conversation + described_class.new(inbox: line_channel.inbox, params: params).perform + + # Mark the conversation as resolved + conversation = line_channel.inbox.conversations.last + conversation.update(status: :resolved) + + # Send a new message + new_params = params.deep_dup + new_params[:events][0][:message][:id] = '325709' + new_params[:events][0][:message][:text] = 'Second message' + + described_class.new(inbox: line_channel.inbox, params: new_params).perform + + # Should create a new conversation + expect(line_channel.inbox.conversations.count).to eq(2) + expect(line_channel.inbox.conversations.last.messages.first.content).to eq('Second message') + end + + it 'uses the existing conversation when there is an unresolved conversation' do + line_bot = double + line_user_profile = double + allow(Line::Bot::Client).to receive(:new).and_return(line_bot) + allow(line_bot).to receive(:get_profile).and_return(line_user_profile) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + + # Create a contact and an unresolved conversation + described_class.new(inbox: line_channel.inbox, params: params).perform + + # Send a new message + new_params = params.deep_dup + new_params[:events][0][:message][:id] = '325709' + new_params[:events][0][:message][:text] = 'Second message' + + described_class.new(inbox: line_channel.inbox, params: new_params).perform + + # Should use the same conversation + expect(line_channel.inbox.conversations.count).to eq(1) + expect(line_channel.inbox.conversations.last.messages.count).to eq(2) + expect(line_channel.inbox.conversations.last.messages.last.content).to eq('Second message') + end + end + + context 'when lock_to_single_conversation is true' do + before do + line_channel.inbox.update(lock_to_single_conversation: true) + end + + it 'uses the existing conversation even when it is resolved' do + line_bot = double + line_user_profile = double + allow(Line::Bot::Client).to receive(:new).and_return(line_bot) + allow(line_bot).to receive(:get_profile).and_return(line_user_profile) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + + # Create a contact and a resolved conversation + described_class.new(inbox: line_channel.inbox, params: params).perform + + # Mark the conversation as resolved + conversation = line_channel.inbox.conversations.last + conversation.update(status: :resolved) + + # Send a new message + new_params = params.deep_dup + new_params[:events][0][:message][:id] = '325709' + new_params[:events][0][:message][:text] = 'Second message' + + described_class.new(inbox: line_channel.inbox, params: new_params).perform + + # Should use the same conversation + expect(line_channel.inbox.conversations.count).to eq(1) + expect(line_channel.inbox.conversations.last.messages.count).to eq(2) + expect(line_channel.inbox.conversations.last.messages.last.content).to eq('Second message') + end + end end end diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb index 528161afe..b81b18756 100644 --- a/spec/services/telegram/incoming_message_service_spec.rb +++ b/spec/services/telegram/incoming_message_service_spec.rb @@ -410,6 +410,94 @@ describe Telegram::IncomingMessageService do expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('contact') end end + + context 'when lock_to_single_conversation is false' do + before do + telegram_channel.inbox.update(lock_to_single_conversation: false) + end + + it 'creates a new conversation when all previous conversations are resolved' do + # Create a contact and a resolved conversation + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { 'text' => 'first message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: params).perform + + # Mark the conversation as resolved + conversation = telegram_channel.inbox.conversations.last + conversation.update(status: :resolved) + + # Send a new message + new_params = { + 'update_id' => 2_342_342_343_243, + 'message' => { 'text' => 'second message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: new_params).perform + + # Should create a new conversation + expect(telegram_channel.inbox.conversations.count).to eq(2) + expect(telegram_channel.inbox.conversations.last.messages.first.content).to eq('second message') + end + + it 'uses the existing conversation when there is an unresolved conversation' do + # Create a contact and an unresolved conversation + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { 'text' => 'first message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: params).perform + + # Send a new message + new_params = { + 'update_id' => 2_342_342_343_243, + 'message' => { 'text' => 'second message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: new_params).perform + + # Should use the same conversation + expect(telegram_channel.inbox.conversations.count).to eq(1) + expect(telegram_channel.inbox.conversations.last.messages.count).to eq(2) + expect(telegram_channel.inbox.conversations.last.messages.last.content).to eq('second message') + end + end + + context 'when lock_to_single_conversation is true' do + before do + telegram_channel.inbox.update(lock_to_single_conversation: true) + end + + it 'uses the existing conversation even when it is resolved' do + # Create a contact and a resolved conversation + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { 'text' => 'first message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: params).perform + + # Mark the conversation as resolved + conversation = telegram_channel.inbox.conversations.last + conversation.update(status: :resolved) + + # Send a new message + new_params = { + 'update_id' => 2_342_342_343_243, + 'message' => { 'text' => 'second message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: new_params).perform + + # Should use the same conversation + expect(telegram_channel.inbox.conversations.count).to eq(1) + expect(telegram_channel.inbox.conversations.last.messages.count).to eq(2) + expect(telegram_channel.inbox.conversations.last.messages.last.content).to eq('second message') + end + end end context 'when lock to single conversation is enabled' do diff --git a/spec/services/tiktok/message_service_spec.rb b/spec/services/tiktok/message_service_spec.rb index fbef64336..ee7b113ac 100644 --- a/spec/services/tiktok/message_service_spec.rb +++ b/spec/services/tiktok/message_service_spec.rb @@ -6,8 +6,29 @@ RSpec.describe Tiktok::MessageService do let(:inbox) { channel.inbox } let(:contact) { create(:contact, account: account) } let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') } + let(:text_content) do + { + type: 'text', + message_id: 'tt-msg-lock', + timestamp: 1_700_000_000_000, + conversation_id: 'tt-conv-1', + text: { body: 'Hello from TikTok' }, + from: 'Alice', + from_user: { id: 'user-1' }, + to: 'Biz', + to_user: { id: 'biz-123' } + }.deep_symbolize_keys + end describe '#perform' do + subject(:perform_text_message) do + service = described_class.new(channel: channel, content: current_content) + allow(service).to receive(:create_contact_inbox).and_return(contact_inbox) + service.perform + end + + let(:current_content) { text_content } + it 'creates an incoming text message' do content = { type: 'text', @@ -113,5 +134,31 @@ RSpec.describe Tiktok::MessageService do ensure tempfile.close! end + + context 'when lock_to_single_conversation is enabled' do + it 'reuses the last resolved conversation' do + inbox.update!(lock_to_single_conversation: true) + resolved_conversation = create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :resolved) + + perform_text_message + + expect(inbox.conversations.count).to eq(1) + expect(resolved_conversation.reload.messages.last.content).to eq('Hello from TikTok') + end + end + + context 'when lock_to_single_conversation is disabled' do + let(:current_content) { text_content.merge(message_id: 'tt-msg-lock-2') } + + it 'creates a new conversation if the previous one is resolved' do + inbox.update!(lock_to_single_conversation: false) + create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :resolved) + + perform_text_message + + expect(inbox.conversations.count).to eq(2) + expect(inbox.conversations.last.messages.last.content).to eq('Hello from TikTok') + end + end end end diff --git a/spec/services/tiktok/read_status_service_spec.rb b/spec/services/tiktok/read_status_service_spec.rb index f0ec116ff..3c44cc738 100644 --- a/spec/services/tiktok/read_status_service_spec.rb +++ b/spec/services/tiktok/read_status_service_spec.rb @@ -31,5 +31,31 @@ RSpec.describe Tiktok::ReadStatusService do expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, kind_of(Time)) end + + it 'updates the latest active conversation when lock_to_single_conversation is disabled' do + allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later) + + inbox.update!(lock_to_single_conversation: false) + conversation.update!(status: :resolved) + active_conversation = create( + :conversation, + account: account, + inbox: inbox, + contact: contact, + contact_inbox: contact_inbox, + status: :open, + additional_attributes: { conversation_id: 'tt-conv-1' } + ) + + content = { + conversation_id: 'tt-conv-1', + read: { last_read_timestamp: 1_700_000_000_000 }, + from_user: { id: 'user-1' } + }.deep_symbolize_keys + + described_class.new(channel: channel, content: content).perform + + expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(active_conversation.id, kind_of(Time)) + end end end From ba804e0f3094dadce7f2e23dc8c4278d917fa622 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:52:45 +0530 Subject: [PATCH 07/24] fix: Upgrade `pico-search` to `0.6.0` (#13645) --- .../routes/dashboard/settings/canned/Index.vue | 5 ++++- .../routes/dashboard/settings/labels/Index.vue | 5 ++++- .../shared/components/ui/label/LabelDropdown.vue | 4 +--- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/settings/canned/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/canned/Index.vue index 1a5c067de..5c1810b63 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/canned/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/canned/Index.vue @@ -45,7 +45,10 @@ const records = computed(() => const filteredRecords = computed(() => { const query = searchQuery.value.trim(); if (!query) return records.value; - return picoSearch(records.value, query, ['short_code', 'content']); + return picoSearch(records.value, query, [ + { name: 'short_code', weight: 4 }, + 'content', + ]); }); const uiFlags = computed(() => getters.getUIFlags.value); diff --git a/app/javascript/dashboard/routes/dashboard/settings/labels/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/labels/Index.vue index c15f7df28..9b81f30cf 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/labels/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/labels/Index.vue @@ -32,7 +32,10 @@ const records = computed(() => getters['labels/getLabels'].value); const filteredRecords = computed(() => { const query = searchQuery.value.trim(); if (!query) return records.value; - return picoSearch(records.value, query, ['title', 'description']); + return picoSearch(records.value, query, [ + { name: 'title', weight: 4 }, + 'description', + ]); }); const uiFlags = computed(() => getters['labels/getUIFlags'].value); diff --git a/app/javascript/shared/components/ui/label/LabelDropdown.vue b/app/javascript/shared/components/ui/label/LabelDropdown.vue index a1216acd8..71da844e0 100644 --- a/app/javascript/shared/components/ui/label/LabelDropdown.vue +++ b/app/javascript/shared/components/ui/label/LabelDropdown.vue @@ -46,9 +46,7 @@ export default { filteredActiveLabels() { if (!this.search) return this.accountLabels; - return picoSearch(this.accountLabels, this.search, ['title'], { - threshold: 0.9, - }); + return picoSearch(this.accountLabels, this.search, ['title']); }, noResult() { diff --git a/package.json b/package.json index 7877a7170..b1aa60f00 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@radix-ui/colors": "^3.0.0", "@rails/actioncable": "6.1.3", "@rails/ujs": "^7.1.400", - "@scmmishra/pico-search": "0.5.4", + "@scmmishra/pico-search": "0.6.0", "@sentry/vue": "^8.55.0", "@sindresorhus/slugify": "2.2.1", "@tailwindcss/typography": "^0.5.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f038f123..539a99b21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,8 +59,8 @@ importers: specifier: ^7.1.400 version: 7.1.400 '@scmmishra/pico-search': - specifier: 0.5.4 - version: 0.5.4 + specifier: 0.6.0 + version: 0.6.0 '@sentry/vue': specifier: ^8.55.0 version: 8.55.0(pinia@3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)))(vue@3.5.12(typescript@5.6.2)) @@ -1240,8 +1240,8 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@scmmishra/pico-search@0.5.4': - resolution: {integrity: sha512-JdV8KumQ+pE5tqgQ71xUT9biE/qV//tx3NCqTLkW9Z4tsjKGN0B6kVowmtaZBAtErqir9XiMxsKXRTMF/MpUww==} + '@scmmishra/pico-search@0.6.0': + resolution: {integrity: sha512-1zC2cAwPWuv38VEh0It90fdUWkvX75OwBUjgTj+d5LTltARnf3ydbpcN2Ucl0aATBMmaNqPMcVvT25IOCAqCEA==} '@sentry-internal/browser-utils@8.55.0': resolution: {integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==} @@ -5795,7 +5795,7 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@scmmishra/pico-search@0.5.4': {} + '@scmmishra/pico-search@0.6.0': {} '@sentry-internal/browser-utils@8.55.0': dependencies: From b98c614669e59c7b63305f6ba0d838b38cd9951a Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 25 Feb 2026 18:33:37 +0530 Subject: [PATCH 08/24] feat: add campaign context to Captain v2 prompts (#13644) Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> --- enterprise/app/models/concerns/agentable.rb | 4 ++- .../captain/assistant/agent_runner_service.rb | 3 ++ .../lib/captain/prompts/assistant.liquid | 6 +++- .../lib/captain/prompts/scenario.liquid | 6 +++- .../captain/prompts/snippets/campaign.liquid | 8 +++++ .../models/concerns/agentable_spec.rb | 26 ++++++++++++-- .../assistant/agent_runner_service_spec.rb | 35 +++++++++++++++++++ 7 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 enterprise/lib/captain/prompts/snippets/campaign.liquid diff --git a/enterprise/app/models/concerns/agentable.rb b/enterprise/app/models/concerns/agentable.rb index e5b0b8eef..ed8e0a89f 100644 --- a/enterprise/app/models/concerns/agentable.rb +++ b/enterprise/app/models/concerns/agentable.rb @@ -19,9 +19,11 @@ module Concerns::Agentable state = context.context[:state] || {} conversation_data = state[:conversation] || {} contact_data = state[:contact] || {} + campaign_data = state[:campaign] || {} enhanced_context = enhanced_context.merge( conversation: conversation_data, - contact: contact_data + contact: contact_data, + campaign: campaign_data ) end diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb index 72c44024f..8238ca4d8 100644 --- a/enterprise/app/services/captain/assistant/agent_runner_service.rb +++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb @@ -16,6 +16,8 @@ class Captain::Assistant::AgentRunnerService custom_attributes additional_attributes ].freeze + CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze + def initialize(assistant:, conversation: nil, callbacks: {}) @assistant = assistant @conversation = conversation @@ -129,6 +131,7 @@ class Captain::Assistant::AgentRunnerService state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES) state[:channel_type] = @conversation.inbox&.channel_type state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact + state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign end state diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid index 0dc7d8577..7b20a089e 100644 --- a/enterprise/lib/captain/prompts/assistant.liquid +++ b/enterprise/lib/captain/prompts/assistant.liquid @@ -8,7 +8,7 @@ You are {{name}}, a helpful and knowledgeable assistant. Your role is to primari Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the `captain--tools--faq_lookup` tool for this. -{% if conversation || contact -%} +{% if conversation || contact || campaign.id -%} # Current Context Here's the metadata we have about the current conversation and the contact associated with it: @@ -20,6 +20,10 @@ Here's the metadata we have about the current conversation and the contact assoc {% if contact -%} {% render 'contact' %} {% endif -%} + +{% if campaign.id -%} +{% render 'campaign' %} +{% endif -%} {% endif -%} {% if response_guidelines.size > 0 -%} diff --git a/enterprise/lib/captain/prompts/scenario.liquid b/enterprise/lib/captain/prompts/scenario.liquid index 1148a7c3a..879a39f52 100644 --- a/enterprise/lib/captain/prompts/scenario.liquid +++ b/enterprise/lib/captain/prompts/scenario.liquid @@ -8,7 +8,7 @@ You are a specialized agent called "{{ title }}", your task is to handle the fol If you believe the user's request is not within the scope of your role, you can assign this conversation back to the orchestrator agent using the `handoff_to_{{ assistant_name }}` tool -{% if conversation || contact %} +{% if conversation || contact || campaign.id %} # Current Context Here's the metadata we have about the current conversation and the contact associated with it: @@ -20,6 +20,10 @@ Here's the metadata we have about the current conversation and the contact assoc {% if contact -%} {% render 'contact' %} {% endif -%} + +{% if campaign.id -%} +{% render 'campaign' %} +{% endif -%} {% endif -%} diff --git a/enterprise/lib/captain/prompts/snippets/campaign.liquid b/enterprise/lib/captain/prompts/snippets/campaign.liquid new file mode 100644 index 000000000..db2ac0e8e --- /dev/null +++ b/enterprise/lib/captain/prompts/snippets/campaign.liquid @@ -0,0 +1,8 @@ +# Campaign Context +This conversation was initiated in response to a campaign message. +- Campaign: {{ campaign.title }} +- Type: {{ campaign.campaign_type }} +{% if campaign.description -%} +- Description: {{ campaign.description }} +{% endif -%} +- Original Message Sent: {{ campaign.message }} diff --git a/spec/enterprise/models/concerns/agentable_spec.rb b/spec/enterprise/models/concerns/agentable_spec.rb index 6b170e8d7..fbf6a58dc 100644 --- a/spec/enterprise/models/concerns/agentable_spec.rb +++ b/spec/enterprise/models/concerns/agentable_spec.rb @@ -97,7 +97,8 @@ RSpec.describe Concerns::Agentable do expected_context = { base_key: 'base_value', conversation: { id: 123 }, - contact: { name: 'John' } + contact: { name: 'John' }, + campaign: {} } expect(Captain::PromptRenderer).to receive(:render).with( @@ -108,6 +109,26 @@ RSpec.describe Concerns::Agentable do dummy_instance.agent_instructions(context_double) end + it 'merges campaign data from context state' do + context_double = instance_double(Agents::RunContext, + context: { + state: { + conversation: { id: 123 }, + contact: { name: 'John' }, + campaign: { id: 10, title: 'Summer Sale', message: 'Check it out' } + } + }) + + expect(Captain::PromptRenderer).to receive(:render).with( + 'dummy_class', + hash_including( + campaign: { id: 10, title: 'Summer Sale', message: 'Check it out' } + ) + ) + + dummy_instance.agent_instructions(context_double) + end + it 'handles context without state' do context_double = instance_double(Agents::RunContext, context: {}) @@ -116,7 +137,8 @@ RSpec.describe Concerns::Agentable do hash_including( base_key: 'base_value', conversation: {}, - contact: {} + contact: {}, + campaign: {} ) ) diff --git a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb index 13e4804ce..0d22b8266 100644 --- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb +++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb @@ -394,6 +394,34 @@ RSpec.describe Captain::Assistant::AgentRunnerService do ) end + it 'does not include campaign when conversation has no campaign' do + state = service.send(:build_state) + + expect(state).not_to have_key(:campaign) + end + + context 'when conversation has a campaign' do + let(:campaign) { create(:campaign, account: account, title: 'Summer Sale', message: 'Check out our deals!', description: 'Seasonal promo') } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, campaign: campaign) } + + it 'includes campaign attributes in state' do + state = service.send(:build_state) + + expect(state[:campaign]).to include( + id: campaign.id, + title: 'Summer Sale', + message: 'Check out our deals!', + description: 'Seasonal promo' + ) + end + + it 'only includes attributes defined in CAMPAIGN_STATE_ATTRIBUTES' do + state = service.send(:build_state) + + expect(state[:campaign].keys).to match_array(described_class::CAMPAIGN_STATE_ATTRIBUTES) + end + end + context 'when conversation is nil' do subject(:service) { described_class.new(assistant: assistant, conversation: nil) } @@ -407,6 +435,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do ) expect(state).not_to have_key(:conversation) expect(state).not_to have_key(:contact) + expect(state).not_to have_key(:campaign) end end end @@ -477,5 +506,11 @@ RSpec.describe Captain::Assistant::AgentRunnerService do :id, :name, :email, :phone_number, :identifier, :contact_type ) end + + it 'defines campaign state attributes' do + expect(described_class::CAMPAIGN_STATE_ATTRIBUTES).to include( + :id, :title, :message, :campaign_type, :description + ) + end end end From 5aef9d2dd0f8a9a4b61c2e16531aaca98ed6fc2a Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:18:34 +0530 Subject: [PATCH 09/24] fix: Conversation list overlay issue with `Virtua` virtualizer (#13648) --- .../dashboard/components/ChatList.vue | 2 -- .../dashboard/components/ConversationItem.vue | 1 - .../dashboard/components/ui/TimeAgo.vue | 14 +++++++++++- .../widgets/conversation/ConversationCard.vue | 22 ++++++++++++++----- .../widgets/conversation/PriorityMark.vue | 1 - 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index a060bcc4f..eeb2ad6e7 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -955,10 +955,8 @@ watch(conversationFilters, (newVal, oldVal) => { ref="virtualListRef" v-slot="{ item, index }" :data="conversationList" - :overscan="10" > diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index 9f7805b4e..f12b0c9c1 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -1,5 +1,5 @@ diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue index 3f4b31299..3bcef1ca2 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue @@ -3,6 +3,8 @@ import { useVuelidate } from '@vuelidate/core'; import { required, url, minLength } from '@vuelidate/validators'; import wootConstants from 'dashboard/constants/globals'; import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper'; +import { copyTextToClipboard } from 'shared/helpers/clipboard'; +import { useAlert } from 'dashboard/composables'; import NextButton from 'dashboard/components-next/button/Button.vue'; const { EXAMPLE_WEBHOOK_URL } = wootConstants; @@ -57,10 +59,14 @@ export default { url: this.value.url || '', name: this.value.name || '', subscriptions: this.value.subscriptions || [], + secretVisible: false, supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS, }; }, computed: { + hasSecret() { + return !!this.value.secret; + }, webhookURLInputPlaceholder() { return this.$t( 'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER', @@ -81,6 +87,10 @@ export default { subscriptions: this.subscriptions, }); }, + async copySecret() { + await copyTextToClipboard(this.value.secret); + useAlert(this.$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS')); + }, getI18nKey, }, }; @@ -111,6 +121,35 @@ export default { :placeholder="webhookNameInputPlaceholder" /> + diff --git a/app/javascript/dashboard/store/modules/webhooks.js b/app/javascript/dashboard/store/modules/webhooks.js index eb096468e..774c173d3 100644 --- a/app/javascript/dashboard/store/modules/webhooks.js +++ b/app/javascript/dashboard/store/modules/webhooks.js @@ -42,6 +42,7 @@ export const actions = { } = response.data; commit(types.default.ADD_WEBHOOK, webhook); commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false }); + return webhook; } catch (error) { commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false }); throw error; diff --git a/app/jobs/webhook_job.rb b/app/jobs/webhook_job.rb index 57d3739b7..54eac45e3 100644 --- a/app/jobs/webhook_job.rb +++ b/app/jobs/webhook_job.rb @@ -1,7 +1,7 @@ class WebhookJob < ApplicationJob queue_as :medium # There are 3 types of webhooks, account, inbox and agent_bot - def perform(url, payload, webhook_type = :account_webhook) - Webhooks::Trigger.execute(url, payload, webhook_type) + def perform(url, payload, webhook_type = :account_webhook, secret: nil, delivery_id: nil) + Webhooks::Trigger.execute(url, payload, webhook_type, secret: secret, delivery_id: delivery_id) end end diff --git a/app/listeners/webhook_listener.rb b/app/listeners/webhook_listener.rb index 82a9fc711..762eaa6ee 100644 --- a/app/listeners/webhook_listener.rb +++ b/app/listeners/webhook_listener.rb @@ -111,7 +111,9 @@ class WebhookListener < BaseListener account.webhooks.account_type.each do |webhook| next unless webhook.subscriptions.include?(payload[:event]) - WebhookJob.perform_later(webhook.url, payload) + WebhookJob.perform_later(webhook.url, payload, :account_webhook, + secret: webhook.secret, + delivery_id: SecureRandom.uuid) end end @@ -119,7 +121,8 @@ class WebhookListener < BaseListener return unless inbox.channel_type == 'Channel::Api' return if inbox.channel.webhook_url.blank? - WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook) + WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook, + delivery_id: SecureRandom.uuid) end def deliver_webhook_payloads(payload, inbox) diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 1d61c1614..6b36c4bbd 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -21,6 +21,9 @@ class Webhook < ApplicationRecord belongs_to :account belongs_to :inbox, optional: true + has_secure_token :secret + encrypts :secret if Chatwoot.encryption_configured? + validates :account_id, presence: true validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]) validate :validate_webhook_subscriptions diff --git a/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder b/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder index 5406cf183..7b1943c5d 100644 --- a/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder +++ b/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder @@ -3,6 +3,7 @@ json.name webhook.name json.url webhook.url json.account_id webhook.account_id json.subscriptions webhook.subscriptions +json.secret webhook.secret if webhook.inbox json.inbox do json.id webhook.inbox.id diff --git a/db/migrate/20260218075101_add_secret_to_webhooks.rb b/db/migrate/20260218075101_add_secret_to_webhooks.rb new file mode 100644 index 000000000..ff6c40c4c --- /dev/null +++ b/db/migrate/20260218075101_add_secret_to_webhooks.rb @@ -0,0 +1,5 @@ +class AddSecretToWebhooks < ActiveRecord::Migration[7.1] + def change + add_column :webhooks, :secret, :string + end +end diff --git a/db/migrate/20260226084618_backfill_webhook_secrets.rb b/db/migrate/20260226084618_backfill_webhook_secrets.rb new file mode 100644 index 000000000..aec6cfde7 --- /dev/null +++ b/db/migrate/20260226084618_backfill_webhook_secrets.rb @@ -0,0 +1,11 @@ +class BackfillWebhookSecrets < ActiveRecord::Migration[7.1] + def up + Webhook.find_each do |webhook| + webhook.update!(secret: SecureRandom.urlsafe_base64(24)) + end + end + + def down + # no-op: removing the column in the previous migration handles cleanup + end +end diff --git a/db/schema.rb b/db/schema.rb index fd4d18cb1..8a450e734 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do +ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -1250,6 +1250,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do t.integer "webhook_type", default: 0 t.jsonb "subscriptions", default: ["conversation_status_changed", "conversation_updated", "conversation_created", "contact_created", "contact_updated", "message_created", "message_updated", "webwidget_triggered"] t.string "name" + t.string "secret" t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true end diff --git a/enterprise/app/models/enterprise/audit/webhook.rb b/enterprise/app/models/enterprise/audit/webhook.rb index 34e0bcc1b..303141ce2 100644 --- a/enterprise/app/models/enterprise/audit/webhook.rb +++ b/enterprise/app/models/enterprise/audit/webhook.rb @@ -2,6 +2,6 @@ module Enterprise::Audit::Webhook extend ActiveSupport::Concern included do - audited associated_with: :account + audited associated_with: :account, except: [:secret] end end diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb index ef3410b78..456e186ca 100644 --- a/lib/webhooks/trigger.rb +++ b/lib/webhooks/trigger.rb @@ -1,14 +1,16 @@ class Webhooks::Trigger SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze - def initialize(url, payload, webhook_type) + def initialize(url, payload, webhook_type, secret: nil, delivery_id: nil) @url = url @payload = payload @webhook_type = webhook_type + @secret = secret + @delivery_id = delivery_id end - def self.execute(url, payload, webhook_type) - new(url, payload, webhook_type).execute + def self.execute(url, payload, webhook_type, secret: nil, delivery_id: nil) + new(url, payload, webhook_type, secret: secret, delivery_id: delivery_id).execute end def execute @@ -21,15 +23,27 @@ class Webhooks::Trigger private def perform_request + body = @payload.to_json RestClient::Request.execute( method: :post, url: @url, - payload: @payload.to_json, - headers: { content_type: :json, accept: :json }, + payload: body, + headers: request_headers(body), timeout: webhook_timeout ) end + def request_headers(body) + headers = { content_type: :json, accept: :json } + headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present? + if @secret.present? + ts = Time.now.to_i.to_s + headers['X-Chatwoot-Timestamp'] = ts + headers['X-Chatwoot-Signature'] = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', @secret, "#{ts}.#{body}")}" + end + headers + end + def handle_error(error) return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event]) return unless message @@ -72,7 +86,11 @@ class Webhooks::Trigger def message return if message_id.blank? - @message ||= Message.find_by(id: message_id) + if defined?(@message) + @message + else + @message = Message.find_by(id: message_id) + end end def message_id diff --git a/spec/jobs/agent_bots/webhook_job_spec.rb b/spec/jobs/agent_bots/webhook_job_spec.rb index a8117d84e..346d85e83 100644 --- a/spec/jobs/agent_bots/webhook_job_spec.rb +++ b/spec/jobs/agent_bots/webhook_job_spec.rb @@ -16,7 +16,7 @@ RSpec.describe AgentBots::WebhookJob do end it 'executes perform' do - expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type) + expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil) perform_enqueued_jobs { job } end end diff --git a/spec/jobs/webhook_job_spec.rb b/spec/jobs/webhook_job_spec.rb index 81802a3c0..c74c1d8a8 100644 --- a/spec/jobs/webhook_job_spec.rb +++ b/spec/jobs/webhook_job_spec.rb @@ -16,7 +16,7 @@ RSpec.describe WebhookJob do end it 'executes perform with default webhook type' do - expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type) + expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil) perform_enqueued_jobs { job } end @@ -24,7 +24,7 @@ RSpec.describe WebhookJob do let(:webhook_type) { :api_inbox_webhook } it 'executes perform with inbox webhook type' do - expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type) + expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil) perform_enqueued_jobs { job } end end diff --git a/spec/lib/webhooks/trigger_spec.rb b/spec/lib/webhooks/trigger_spec.rb index 79cf92150..1e047b557 100644 --- a/spec/lib/webhooks/trigger_spec.rb +++ b/spec/lib/webhooks/trigger_spec.rb @@ -168,6 +168,71 @@ describe Webhooks::Trigger do end end + describe 'request headers' do + let(:payload) { { event: 'message_created' } } + let(:body) { payload.to_json } + + context 'without secret or delivery_id' do + it 'sends only content-type and accept headers' do + expect(RestClient::Request).to receive(:execute).with( + hash_including(headers: { content_type: :json, accept: :json }) + ) + trigger.execute(url, payload, webhook_type) + end + end + + context 'with delivery_id' do + it 'adds X-Chatwoot-Delivery header' do + expect(RestClient::Request).to receive(:execute) do |args| + expect(args[:headers]['X-Chatwoot-Delivery']).to eq('test-uuid') + expect(args[:headers]).not_to have_key('X-Chatwoot-Signature') + expect(args[:headers]).not_to have_key('X-Chatwoot-Timestamp') + end + trigger.execute(url, payload, webhook_type, delivery_id: 'test-uuid') + end + end + + context 'with secret' do + let(:secret) { 'test-secret' } + + it 'adds X-Chatwoot-Timestamp header' do + expect(RestClient::Request).to receive(:execute) do |args| + expect(args[:headers]['X-Chatwoot-Timestamp']).to match(/\A\d+\z/) + end + trigger.execute(url, payload, webhook_type, secret: secret) + end + + it 'adds X-Chatwoot-Signature header with correct HMAC' do + expect(RestClient::Request).to receive(:execute) do |args| + ts = args[:headers]['X-Chatwoot-Timestamp'] + expected_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, "#{ts}.#{body}")}" + expect(args[:headers]['X-Chatwoot-Signature']).to eq(expected_sig) + end + trigger.execute(url, payload, webhook_type, secret: secret) + end + + it 'signs timestamp.body not just body' do + expect(RestClient::Request).to receive(:execute) do |args| + args[:headers]['X-Chatwoot-Timestamp'] + wrong_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}" + expect(args[:headers]['X-Chatwoot-Signature']).not_to eq(wrong_sig) + end + trigger.execute(url, payload, webhook_type, secret: secret) + end + end + + context 'with both secret and delivery_id' do + it 'includes all three security headers' do + expect(RestClient::Request).to receive(:execute) do |args| + expect(args[:headers]['X-Chatwoot-Delivery']).to eq('abc-123') + expect(args[:headers]['X-Chatwoot-Timestamp']).to be_present + expect(args[:headers]['X-Chatwoot-Signature']).to start_with('sha256=') + end + trigger.execute(url, payload, webhook_type, secret: 'mysecret', delivery_id: 'abc-123') + end + end + end + it 'does not update message status if webhook fails for other events' do payload = { event: 'conversation_created', conversation: { id: conversation.id }, id: message.id } diff --git a/spec/listeners/webhook_listener_spec.rb b/spec/listeners/webhook_listener_spec.rb index 5062b11bc..51dae239b 100644 --- a/spec/listeners/webhook_listener_spec.rb +++ b/spec/listeners/webhook_listener_spec.rb @@ -28,7 +28,10 @@ describe WebhookListener do context 'when webhook is configured and event is subscribed' do it 'triggers the webhook event' do webhook = create(:webhook, inbox: inbox, account: account) - expect(WebhookJob).to receive(:perform_later).with(webhook.url, message.webhook_data.merge(event: 'message_created')).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, message.webhook_data.merge(event: 'message_created'), :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.message_created(message_created_event) end end @@ -54,8 +57,10 @@ describe WebhookListener do conversation: api_conversation ) api_event = Events::Base.new(event_name, Time.zone.now, message: api_message) - expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'), - :api_inbox_webhook).once + expect(WebhookJob).to receive(:perform_later).with( + channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'), + :api_inbox_webhook, delivery_id: instance_of(String) + ).once listener.message_created(api_event) end @@ -90,7 +95,10 @@ describe WebhookListener do context 'when webhook is configured' do it 'triggers webhook' do webhook = create(:webhook, inbox: inbox, account: account) - expect(WebhookJob).to receive(:perform_later).with(webhook.url, conversation.webhook_data.merge(event: 'conversation_created')).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, conversation.webhook_data.merge(event: 'conversation_created'), :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.conversation_created(conversation_created_event) end end @@ -101,9 +109,11 @@ describe WebhookListener do api_inbox = channel_api.inbox api_conversation = create(:conversation, account: account, inbox: api_inbox, assignee: user) api_event = Events::Base.new(event_name, Time.zone.now, conversation: api_conversation) - expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, - api_conversation.webhook_data.merge(event: 'conversation_created'), - :api_inbox_webhook).once + expect(WebhookJob).to receive(:perform_later).with( + channel_api.webhook_url, + api_conversation.webhook_data.merge(event: 'conversation_created'), + :api_inbox_webhook, delivery_id: instance_of(String) + ).once listener.conversation_created(api_event) end @@ -156,7 +166,9 @@ describe WebhookListener do } } ] - ) + ), + :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) ).once listener.conversation_updated(conversation_updated_event) @@ -177,7 +189,10 @@ describe WebhookListener do context 'when webhook is configured' do it 'triggers webhook' do webhook = create(:webhook, account: account) - expect(WebhookJob).to receive(:perform_later).with(webhook.url, contact.webhook_data.merge(event: 'contact_created')).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, contact.webhook_data.merge(event: 'contact_created'), :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.contact_created(contact_event) end end @@ -213,7 +228,9 @@ describe WebhookListener do contact.webhook_data.merge( event: 'contact_updated', changed_attributes: [{ 'name' => { :current_value => 'Jane Doe', :previous_value => 'Jane' } }] - ) + ), + :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) ).once listener.contact_updated(contact_updated_event) end @@ -235,7 +252,10 @@ describe WebhookListener do it 'triggers webhook' do inbox_data = Inbox::EventDataPresenter.new(inbox).push_data webhook = create(:webhook, account: account, subscriptions: ['inbox_created']) - expect(WebhookJob).to receive(:perform_later).with(webhook.url, inbox_data.merge(event: 'inbox_created')).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, inbox_data.merge(event: 'inbox_created'), :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.inbox_created(inbox_created_event) end end @@ -272,7 +292,9 @@ describe WebhookListener do expect(WebhookJob).to receive(:perform_later).with( webhook.url, - inbox_data.merge(event: 'inbox_updated', changed_attributes: changed_attributes_data) + inbox_data.merge(event: 'inbox_updated', changed_attributes: changed_attributes_data), + :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) ).once listener.inbox_updated(inbox_updated_event) @@ -302,7 +324,10 @@ describe WebhookListener do is_private: false } - expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, payload, :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.conversation_typing_on(typing_event) end end @@ -321,7 +346,10 @@ describe WebhookListener do is_private: false } - expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, payload, :api_inbox_webhook).once + expect(WebhookJob).to receive(:perform_later).with( + channel_api.webhook_url, payload, :api_inbox_webhook, + delivery_id: instance_of(String) + ).once listener.conversation_typing_on(api_event) end end @@ -349,7 +377,10 @@ describe WebhookListener do is_private: false } - expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, payload, :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.conversation_typing_off(typing_event) end end diff --git a/spec/models/webhook_spec.rb b/spec/models/webhook_spec.rb index 81e6d9551..b8570de59 100644 --- a/spec/models/webhook_spec.rb +++ b/spec/models/webhook_spec.rb @@ -8,4 +8,20 @@ RSpec.describe Webhook do describe 'associations' do it { is_expected.to belong_to(:account) } end + + describe 'secret token' do + let!(:account) { create(:account) } + + it 'auto-generates a secret on create' do + webhook = create(:webhook, account: account) + expect(webhook.secret).to be_present + end + + it 'does not regenerate the secret on update' do + webhook = create(:webhook, account: account) + original_secret = webhook.secret + webhook.update!(url: "#{webhook.url}?updated=1") + expect(webhook.reload.secret).to eq(original_secret) + end + end end From 9ca03c1af31e4b059d1a97324db1a68f3f7a81eb Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:01:13 +0530 Subject: [PATCH 18/24] chore: make all the deprecated feature flag reclaimable (#13646) ## Docs https://www.notion.so/chatwoot/Redeeming-a-depreciated-feature-flag-313a5f274c9280f381cdd811eab42019?source=copy_link ## Description Marks 8 unused feature flags as deprecated: true in features.yml, freeing their bit slots for future reuse. Removes dead code references from JS constants, help URLs, and enterprise billing config. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Simulated the "claim a slot" workflow ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- app/javascript/dashboard/featureFlags.js | 3 --- app/javascript/dashboard/helper/featureHelper.js | 1 - config/features.yml | 4 +--- .../enterprise/billing/handle_stripe_event_service.rb | 1 - enterprise/config/premium_features.yml | 1 - 5 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 353bed96e..858c0ecbc 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -21,10 +21,8 @@ export const FEATURE_FLAGS = { AUDIT_LOGS: 'audit_logs', INBOX_VIEW: 'inbox_view', SLA: 'sla', - RESPONSE_BOT: 'response_bot', CHANNEL_EMAIL: 'channel_email', CHANNEL_FACEBOOK: 'channel_facebook', - CHANNEL_TWITTER: 'channel_twitter', CHANNEL_WEBSITE: 'channel_website', CUSTOM_REPLY_DOMAIN: 'custom_reply_domain', CUSTOM_REPLY_EMAIL: 'custom_reply_email', @@ -36,7 +34,6 @@ export const FEATURE_FLAGS = { CAPTAIN: 'captain_integration', CUSTOM_ROLES: 'custom_roles', CHATWOOT_V4: 'chatwoot_v4', - REPORT_V4: 'report_v4', CHANNEL_INSTAGRAM: 'channel_instagram', CHANNEL_TIKTOK: 'channel_tiktok', CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', diff --git a/app/javascript/dashboard/helper/featureHelper.js b/app/javascript/dashboard/helper/featureHelper.js index ae805ccf1..c90ec15db 100644 --- a/app/javascript/dashboard/helper/featureHelper.js +++ b/app/javascript/dashboard/helper/featureHelper.js @@ -13,7 +13,6 @@ const FEATURE_HELP_URLS = { integrations: 'https://chwt.app/hc/integrations', labels: 'https://chwt.app/hc/labels', macros: 'https://chwt.app/hc/macros', - message_reply_to: 'https://chwt.app/hc/reply-to', reports: 'https://chwt.app/hc/reports', sla: 'https://chwt.app/hc/sla', team_management: 'https://chwt.app/hc/teams', diff --git a/config/features.yml b/config/features.yml index d8378d61c..65b3c6194 100644 --- a/config/features.yml +++ b/config/features.yml @@ -108,12 +108,10 @@ - name: response_bot display_name: Response Bot enabled: false - premium: true deprecated: true - name: message_reply_to display_name: Message Reply To enabled: false - help_url: https://chwt.app/hc/reply-to deprecated: true - name: insert_article_in_reply display_name: Insert Article in Reply @@ -149,7 +147,7 @@ enabled: true - name: report_v4 display_name: Report V4 - enabled: true + enabled: false deprecated: true - name: contact_chatwoot_support_team display_name: Contact Chatwoot Support Team diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index 52a28844f..d3c5b15db 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -11,7 +11,6 @@ class Enterprise::Billing::HandleStripeEventService help_center campaigns team_management - channel_twitter channel_facebook channel_email channel_instagram diff --git a/enterprise/config/premium_features.yml b/enterprise/config/premium_features.yml index 64275503d..0cb89df01 100644 --- a/enterprise/config/premium_features.yml +++ b/enterprise/config/premium_features.yml @@ -1,7 +1,6 @@ # List of the premium features in EE edition - disable_branding - audit_logs -- response_bot - sla - custom_roles - captain_integration From 7acd239c7050887fc9b0c5dd562e545a96080abc Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:01:23 +0530 Subject: [PATCH 19/24] fix: call authorization_error! on IMAP auth failures (#13560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Notion document https://www.notion.so/chatwoot/Email-IMAP-Issue-30aa5f274c928062aa6bddc2e5877a63?showMoveTo=true&saveParent=true ## Description PLAIN IMAP channels (non-OAuth) were silently retrying failed authentication every minute, forever. When credentials are wrong/expired, Net::IMAP::NoResponseError was caught and logged but channel.authorization_error! was never called — so the Redis error counter never incremented, reauthorization_required? was never set, and admins were never notified. OAuth channels already had this handled correctly via the Reauthorizable concern. Additionally, Net::IMAP::ResponseParseError (raised by non-RFC-compliant IMAP servers) was falling through to the StandardError catch-all, flooding Estimated impact before fix: ~70–75 broken IMAP inboxes generating ~700k–750k wasted Sidekiq jobs/week. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../inboxes/fetch_imap_email_inboxes_job.rb | 1 + app/jobs/inboxes/fetch_imap_emails_job.rb | 29 ++++---- app/models/channel/email.rb | 4 ++ app/models/concerns/backoffable.rb | 70 +++++++++++++++++++ app/models/concerns/reauthorizable.rb | 3 + app/services/imap/authentication_error.rb | 1 + app/services/imap/base_fetch_email_service.rb | 6 +- config/installation_config.yml | 13 ++++ lib/exception_list.rb | 6 ++ lib/redis/redis_keys.rb | 4 ++ .../inboxes/fetch_imap_emails_job_spec.rb | 50 +++++++++++++ spec/models/channel/email_spec.rb | 2 + spec/models/concerns/backoffable_shared.rb | 43 ++++++++++++ 13 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 app/models/concerns/backoffable.rb create mode 100644 app/services/imap/authentication_error.rb create mode 100644 spec/models/concerns/backoffable_shared.rb diff --git a/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb index ea2705955..ea7f7664f 100644 --- a/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb +++ b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb @@ -15,6 +15,7 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob return false if inbox.account.suspended? return false unless inbox.channel.imap_enabled return false if inbox.channel.reauthorization_required? + return false if inbox.channel.in_backoff? return true unless ChatwootApp.chatwoot_cloud? return false if default_plan?(inbox.account) diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb index ec5717f3b..74368c830 100644 --- a/app/jobs/inboxes/fetch_imap_emails_job.rb +++ b/app/jobs/inboxes/fetch_imap_emails_job.rb @@ -6,26 +6,29 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob def perform(channel, interval = 1) return unless should_fetch_email?(channel) - key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) - - with_lock(key, 5.minutes) do - process_email_for_channel(channel, interval) - end - rescue *ExceptionList::IMAP_EXCEPTIONS => e - Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}" - rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError, - Net::IMAP::ResponseParseError, Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError => e - Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}" - rescue LockAcquisitionError - Rails.logger.error "Lock failed for #{channel.inbox.id}" + fetch_emails_with_backoff(channel, interval) rescue StandardError => e ChatwootExceptionTracker.new(e, account: channel.account).capture_exception end private + def fetch_emails_with_backoff(channel, interval) + key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) + with_lock(key, 5.minutes) { process_email_for_channel(channel, interval) } + channel.clear_backoff! + rescue Imap::AuthenticationError => e + Rails.logger.error "#{channel.backoff_log_identifier} authentication error : #{e.message}" + channel.authorization_error! + rescue *ExceptionList::IMAP_TRANSIENT_EXCEPTIONS => e + Rails.logger.error "#{channel.backoff_log_identifier} transient error : #{e.message}" + channel.apply_backoff! + rescue LockAcquisitionError + Rails.logger.error "Lock failed for #{channel.inbox.id}" + end + def should_fetch_email?(channel) - channel.imap_enabled? && !channel.reauthorization_required? + channel.imap_enabled? && !channel.reauthorization_required? && !channel.in_backoff? end def process_email_for_channel(channel, interval) diff --git a/app/models/channel/email.rb b/app/models/channel/email.rb index b1124dd75..1091b17d8 100644 --- a/app/models/channel/email.rb +++ b/app/models/channel/email.rb @@ -72,6 +72,10 @@ class Channel::Email < ApplicationRecord imap_enabled && imap_address == 'imap.gmail.com' end + def backoff_log_identifier + "Error for email channel - #{inbox.id}" + end + private def ensure_forward_to_email diff --git a/app/models/concerns/backoffable.rb b/app/models/concerns/backoffable.rb new file mode 100644 index 000000000..60b6ac98c --- /dev/null +++ b/app/models/concerns/backoffable.rb @@ -0,0 +1,70 @@ +# Backoffable provides transient-error retry backoff for models that depend on external services. +# +# When a transient error occurs (network hiccup, SSL failure, etc.) call apply_backoff!. +# The wait time ramps from 1 minute up to BACKOFF_MAX_INTERVAL_MINUTES, then holds at that +# ceiling for BACKOFF_MAX_INTERVAL_COUNT more attempts before calling prompt_reauthorization!. +# +# Call clear_backoff! after a successful operation to reset the counter. + +module Backoffable + extend ActiveSupport::Concern + + def backoff_log_identifier + inbox_id = respond_to?(:inbox) && inbox&.id + inbox_id ? "#{self.class.name} - #{inbox_id}" : "#{self.class.name}##{id}" + end + + def backoff_retry_count + ::Redis::Alfred.get(backoff_retry_count_key).to_i + end + + def in_backoff? + val = ::Redis::Alfred.get(backoff_retry_after_key) + val.present? && Time.zone.at(val.to_f) > Time.current + end + + def apply_backoff! + new_count = backoff_retry_count + 1 + max_interval, max_retries = backoff_limits + + if new_count > max_retries + exhaust_backoff(new_count) + else + schedule_backoff_retry(new_count, max_interval, max_retries) + end + end + + def clear_backoff! + ::Redis::Alfred.delete(backoff_retry_count_key) + ::Redis::Alfred.delete(backoff_retry_after_key) + end + + private + + def backoff_limits + max_interval = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_MINUTES', 5).to_i + max_count = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_COUNT', 10).to_i + [max_interval, (max_interval - 1) + max_count] + end + + def exhaust_backoff(new_count) + Rails.logger.warn "#{backoff_log_identifier} backoff exhausted (#{new_count} failures), prompting reauthorization" + clear_backoff! + prompt_reauthorization! + end + + def schedule_backoff_retry(new_count, max_interval, max_retries) + wait_minutes = [new_count, max_interval].min + ::Redis::Alfred.set(backoff_retry_count_key, new_count.to_s, ex: 24.hours) + ::Redis::Alfred.set(backoff_retry_after_key, wait_minutes.minutes.from_now.to_f.to_s, ex: 24.hours) + Rails.logger.warn "#{backoff_log_identifier} backoff retry #{new_count}/#{max_retries}, next attempt in #{wait_minutes}m" + end + + def backoff_retry_count_key + format(::Redis::Alfred::BACKOFF_RETRY_COUNT, obj_type: self.class.table_name.singularize, obj_id: id) + end + + def backoff_retry_after_key + format(::Redis::Alfred::BACKOFF_RETRY_AFTER, obj_type: self.class.table_name.singularize, obj_id: id) + end +end diff --git a/app/models/concerns/reauthorizable.rb b/app/models/concerns/reauthorizable.rb index 7a09f6436..b25f5525e 100644 --- a/app/models/concerns/reauthorizable.rb +++ b/app/models/concerns/reauthorizable.rb @@ -13,6 +13,8 @@ module Reauthorizable extend ActiveSupport::Concern + include Backoffable + AUTHORIZATION_ERROR_THRESHOLD = 2 # model attribute @@ -65,6 +67,7 @@ module Reauthorizable def reauthorized! ::Redis::Alfred.delete(authorization_error_count_key) ::Redis::Alfred.delete(reauthorization_required_key) + clear_backoff! invalidate_inbox_cache unless instance_of?(::AutomationRule) end diff --git a/app/services/imap/authentication_error.rb b/app/services/imap/authentication_error.rb new file mode 100644 index 000000000..5698f5b3b --- /dev/null +++ b/app/services/imap/authentication_error.rb @@ -0,0 +1 @@ +class Imap::AuthenticationError < StandardError; end diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb index 09332092c..49e06491b 100644 --- a/app/services/imap/base_fetch_email_service.rb +++ b/app/services/imap/base_fetch_email_service.rb @@ -107,7 +107,11 @@ class Imap::BaseFetchEmailService def build_imap_client imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true) - imap.authenticate(authentication_type, channel.imap_login, imap_password) + begin + imap.authenticate(authentication_type, channel.imap_login, imap_password) + rescue Net::IMAP::NoResponseError => e + raise Imap::AuthenticationError, e.message + end imap.select('INBOX') imap end diff --git a/config/installation_config.yml b/config/installation_config.yml index 34cb736bf..0d22de6fc 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -96,6 +96,19 @@ locked: false # ------- End of Account Related Config ------- # +# ------- Transient Error Backoff Config ------- # +- name: BACKOFF_MAX_INTERVAL_MINUTES + display_title: 'Backoff Max Interval (minutes)' + description: 'Maximum wait time in minutes between retry attempts before the backoff plateaus' + value: 5 + locked: false +- name: BACKOFF_MAX_INTERVAL_COUNT + display_title: 'Backoff Max Retry Count' + description: 'Number of additional retries at the maximum interval before prompting reauthorization' + value: 10 + locked: false +# ------- End of Transient Error Backoff Config ------- # + # ------- Email Related Config ------- # - name: MAILER_INBOUND_EMAIL_DOMAIN display_title: 'Inbound Email Domain' diff --git a/lib/exception_list.rb b/lib/exception_list.rb index 2fee0a170..dd468f07b 100644 --- a/lib/exception_list.rb +++ b/lib/exception_list.rb @@ -16,4 +16,10 @@ module ExceptionList Errno::ECONNRESET, Errno::ENETUNREACH, Net::IMAP::ByeResponseError, SocketError ].freeze + + IMAP_TRANSIENT_EXCEPTIONS = (IMAP_EXCEPTIONS + [ + EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, + Net::IMAP::InvalidResponseError, Net::IMAP::ResponseParseError, + Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError + ]).freeze end diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb index 8c9361ab5..fa9eeb31e 100644 --- a/lib/redis/redis_keys.rb +++ b/lib/redis/redis_keys.rb @@ -52,4 +52,8 @@ module Redis::RedisKeys ## Account Email Rate Limiting ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%d::%s'.freeze + + ## Transient Error Backoff + BACKOFF_RETRY_COUNT = 'BACKOFF:%s:%d:retry_count'.freeze + BACKOFF_RETRY_AFTER = 'BACKOFF:%s:%d:retry_after'.freeze end diff --git a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb index da4b95b15..1abd7450b 100644 --- a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb +++ b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb @@ -38,6 +38,14 @@ RSpec.describe Inboxes::FetchImapEmailsJob do end end + context 'when channel is in backoff' do + it 'does not fetch emails' do + allow(imap_email_channel).to receive(:in_backoff?).and_return(true) + expect(Imap::FetchEmailService).not_to receive(:new) + described_class.perform_now(imap_email_channel) + end + end + context 'when the channel is regular imap' do it 'calls the imap fetch service' do fetch_service = double @@ -56,6 +64,17 @@ RSpec.describe Inboxes::FetchImapEmailsJob do described_class.perform_now(imap_email_channel, 4) expect(fetch_service).to have_received(:perform) end + + it 'clears backoff after successful fetch' do + fetch_service = double + allow(Imap::FetchEmailService).to receive(:new).and_return(fetch_service) + allow(fetch_service).to receive(:perform).and_return([]) + allow(imap_email_channel).to receive(:clear_backoff!) + + described_class.perform_now(imap_email_channel) + + expect(imap_email_channel).to have_received(:clear_backoff!) + end end context 'when the channel is Microsoft' do @@ -69,6 +88,37 @@ RSpec.describe Inboxes::FetchImapEmailsJob do end end + context 'when authentication error is raised' do + it 'calls authorization_error! on the channel' do + allow(Imap::FetchEmailService).to receive(:new).and_raise(Imap::AuthenticationError) + allow(imap_email_channel).to receive(:authorization_error!) + + described_class.perform_now(imap_email_channel) + + expect(imap_email_channel).to have_received(:authorization_error!) + end + end + + context 'when a transient IMAP error is raised' do + it 'calls apply_backoff! on the channel' do + allow(Imap::FetchEmailService).to receive(:new).and_raise(EOFError) + allow(imap_email_channel).to receive(:apply_backoff!) + + described_class.perform_now(imap_email_channel) + + expect(imap_email_channel).to have_received(:apply_backoff!) + end + end + + context 'when lock acquisition fails' do + it 'does not raise an error' do + lock_manager = instance_double(Redis::LockManager, lock: false) + allow(Redis::LockManager).to receive(:new).and_return(lock_manager) + + expect { described_class.perform_now(imap_email_channel) }.not_to raise_error + end + end + context 'when IMAP OAuth errors out' do it 'marks the connection as requiring authorization' do error_response = double diff --git a/spec/models/channel/email_spec.rb b/spec/models/channel/email_spec.rb index 939e8e10a..bdb9f859b 100644 --- a/spec/models/channel/email_spec.rb +++ b/spec/models/channel/email_spec.rb @@ -2,12 +2,14 @@ require 'rails_helper' require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb' +require Rails.root.join 'spec/models/concerns/backoffable_shared.rb' RSpec.describe Channel::Email do let(:channel) { create(:channel_email) } describe 'concerns' do it_behaves_like 'reauthorizable' + it_behaves_like 'backoffable' context 'when prompt_reauthorization!' do it 'calls channel notifier mail for email' do diff --git a/spec/models/concerns/backoffable_shared.rb b/spec/models/concerns/backoffable_shared.rb new file mode 100644 index 000000000..385f93be7 --- /dev/null +++ b/spec/models/concerns/backoffable_shared.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +shared_examples_for 'backoffable' do + let(:obj) { FactoryBot.create(described_class.to_s.underscore.tr('/', '_').to_sym) } + + before do + allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_MINUTES', 5).and_return(2) + allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_COUNT', 10).and_return(3) + # max_interval=2, max_retries=(2-1)+3=4; exhausts on 5th apply_backoff! + end + + it 'starts with no backoff' do + expect(obj.in_backoff?).to be false + expect(obj.backoff_retry_count).to eq 0 + end + + it 'ramps backoff on each failure' do + obj.apply_backoff! + expect(obj.backoff_retry_count).to eq 1 + expect(obj.in_backoff?).to be true + end + + it 'caps wait time at max interval' do + 4.times { obj.apply_backoff! } + expect(obj.backoff_retry_count).to eq 4 + expect(obj.in_backoff?).to be true + end + + it 'exhausts backoff and calls prompt_reauthorization! after max retries' do + allow(obj).to receive(:prompt_reauthorization!) + 5.times { obj.apply_backoff! } + expect(obj).to have_received(:prompt_reauthorization!) + expect(obj.backoff_retry_count).to eq 0 + expect(obj.in_backoff?).to be false + end + + it 'clear_backoff! resets retry count and backoff window' do + obj.apply_backoff! + obj.clear_backoff! + expect(obj.in_backoff?).to be false + expect(obj.backoff_retry_count).to eq 0 + end +end From bdcc62f1b004f26ddfa76d73b0d095270e44a043 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 26 Feb 2026 19:05:15 +0400 Subject: [PATCH 20/24] feat(facebook): Mark Messenger native-app echoes as external echo message (#13665) When agents send replies from the native Facebook Messenger app (not Chatwoot), echo events were created without external_echo metadata and could be misrepresented in the UI. This change updates Messenger echo message creation to: - set content_attributes.external_echo = true for outgoing_echo messages - set echo message status to delivered - keep sender as nil for echo messages (existing behavior) CleanShot 2026-02-26 at 16 32
04@2x --- .../messages/facebook/message_builder.rb | 10 +++++-- .../messages/facebook/message_builder_spec.rb | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 2c55922f6..1f59deadb 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -105,15 +105,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder end def message_params + content_attributes = { + in_reply_to_external_id: response.in_reply_to_external_id + } + content_attributes[:external_echo] = true if @outgoing_echo + { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: @message_type, + status: @outgoing_echo ? :delivered : :sent, content: response.content, source_id: response.identifier, - content_attributes: { - in_reply_to_external_id: response.in_reply_to_external_id - }, + content_attributes: content_attributes, sender: @outgoing_echo ? nil : @contact_inbox.contact } end diff --git a/spec/builders/messages/facebook/message_builder_spec.rb b/spec/builders/messages/facebook/message_builder_spec.rb index 4b94c9be4..525ad7736 100644 --- a/spec/builders/messages/facebook/message_builder_spec.rb +++ b/spec/builders/messages/facebook/message_builder_spec.rb @@ -59,6 +59,36 @@ describe Messages::Facebook::MessageBuilder do expect(contact.name).to eq(default_name) end + it 'marks echo messages as external echo messages' do + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) + allow(fb_object).to receive(:get_object).and_return( + { + first_name: 'Jane', + last_name: 'Dae', + account_id: facebook_channel.inbox.account_id, + profile_pic: 'https://chatwoot-assets.local/sample.png' + }.with_indifferent_access + ) + + echo_message_object = { + messaging: { + sender: { id: facebook_channel.page_id }, + recipient: { id: '3383290475046708' }, + message: { mid: 'm_echo_1', text: 'Echo testing', is_echo: true, app_id: '263902037430900' } + } + }.to_json + echo_message = Integrations::Facebook::MessageParser.new(echo_message_object) + + described_class.new(echo_message, facebook_channel.inbox, outgoing_echo: true).perform + + message = facebook_channel.inbox.messages.find_by(source_id: 'm_echo_1') + expect(message).to be_present + expect(message.message_type).to eq('outgoing') + expect(message.sender).to be_nil + expect(message.status).to eq('delivered') + expect(message.content_attributes['external_echo']).to be true + end + context 'when lock to single conversation' do subject(:mocked_message_builder) do described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform From d84ae196d57611ff95bc6f449bbc31496ff05046 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 26 Feb 2026 18:45:18 -0800 Subject: [PATCH 21/24] fix: call authorization_error! on IMAP auth failures (#13560) (revert) (#13671) This reverts commit 7acd239c7050887fc9b0c5dd562e545a96080abc to further debug upstream issues. --- .../inboxes/fetch_imap_email_inboxes_job.rb | 1 - app/jobs/inboxes/fetch_imap_emails_job.rb | 29 ++++---- app/models/channel/email.rb | 4 -- app/models/concerns/backoffable.rb | 70 ------------------- app/models/concerns/reauthorizable.rb | 3 - app/services/imap/authentication_error.rb | 1 - app/services/imap/base_fetch_email_service.rb | 6 +- config/installation_config.yml | 13 ---- lib/exception_list.rb | 6 -- lib/redis/redis_keys.rb | 4 -- .../inboxes/fetch_imap_emails_job_spec.rb | 50 ------------- spec/models/channel/email_spec.rb | 2 - spec/models/concerns/backoffable_shared.rb | 43 ------------ 13 files changed, 14 insertions(+), 218 deletions(-) delete mode 100644 app/models/concerns/backoffable.rb delete mode 100644 app/services/imap/authentication_error.rb delete mode 100644 spec/models/concerns/backoffable_shared.rb diff --git a/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb index ea7f7664f..ea2705955 100644 --- a/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb +++ b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb @@ -15,7 +15,6 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob return false if inbox.account.suspended? return false unless inbox.channel.imap_enabled return false if inbox.channel.reauthorization_required? - return false if inbox.channel.in_backoff? return true unless ChatwootApp.chatwoot_cloud? return false if default_plan?(inbox.account) diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb index 74368c830..ec5717f3b 100644 --- a/app/jobs/inboxes/fetch_imap_emails_job.rb +++ b/app/jobs/inboxes/fetch_imap_emails_job.rb @@ -6,29 +6,26 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob def perform(channel, interval = 1) return unless should_fetch_email?(channel) - fetch_emails_with_backoff(channel, interval) + key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) + + with_lock(key, 5.minutes) do + process_email_for_channel(channel, interval) + end + rescue *ExceptionList::IMAP_EXCEPTIONS => e + Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}" + rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError, + Net::IMAP::ResponseParseError, Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError => e + Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}" + rescue LockAcquisitionError + Rails.logger.error "Lock failed for #{channel.inbox.id}" rescue StandardError => e ChatwootExceptionTracker.new(e, account: channel.account).capture_exception end private - def fetch_emails_with_backoff(channel, interval) - key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) - with_lock(key, 5.minutes) { process_email_for_channel(channel, interval) } - channel.clear_backoff! - rescue Imap::AuthenticationError => e - Rails.logger.error "#{channel.backoff_log_identifier} authentication error : #{e.message}" - channel.authorization_error! - rescue *ExceptionList::IMAP_TRANSIENT_EXCEPTIONS => e - Rails.logger.error "#{channel.backoff_log_identifier} transient error : #{e.message}" - channel.apply_backoff! - rescue LockAcquisitionError - Rails.logger.error "Lock failed for #{channel.inbox.id}" - end - def should_fetch_email?(channel) - channel.imap_enabled? && !channel.reauthorization_required? && !channel.in_backoff? + channel.imap_enabled? && !channel.reauthorization_required? end def process_email_for_channel(channel, interval) diff --git a/app/models/channel/email.rb b/app/models/channel/email.rb index 1091b17d8..b1124dd75 100644 --- a/app/models/channel/email.rb +++ b/app/models/channel/email.rb @@ -72,10 +72,6 @@ class Channel::Email < ApplicationRecord imap_enabled && imap_address == 'imap.gmail.com' end - def backoff_log_identifier - "Error for email channel - #{inbox.id}" - end - private def ensure_forward_to_email diff --git a/app/models/concerns/backoffable.rb b/app/models/concerns/backoffable.rb deleted file mode 100644 index 60b6ac98c..000000000 --- a/app/models/concerns/backoffable.rb +++ /dev/null @@ -1,70 +0,0 @@ -# Backoffable provides transient-error retry backoff for models that depend on external services. -# -# When a transient error occurs (network hiccup, SSL failure, etc.) call apply_backoff!. -# The wait time ramps from 1 minute up to BACKOFF_MAX_INTERVAL_MINUTES, then holds at that -# ceiling for BACKOFF_MAX_INTERVAL_COUNT more attempts before calling prompt_reauthorization!. -# -# Call clear_backoff! after a successful operation to reset the counter. - -module Backoffable - extend ActiveSupport::Concern - - def backoff_log_identifier - inbox_id = respond_to?(:inbox) && inbox&.id - inbox_id ? "#{self.class.name} - #{inbox_id}" : "#{self.class.name}##{id}" - end - - def backoff_retry_count - ::Redis::Alfred.get(backoff_retry_count_key).to_i - end - - def in_backoff? - val = ::Redis::Alfred.get(backoff_retry_after_key) - val.present? && Time.zone.at(val.to_f) > Time.current - end - - def apply_backoff! - new_count = backoff_retry_count + 1 - max_interval, max_retries = backoff_limits - - if new_count > max_retries - exhaust_backoff(new_count) - else - schedule_backoff_retry(new_count, max_interval, max_retries) - end - end - - def clear_backoff! - ::Redis::Alfred.delete(backoff_retry_count_key) - ::Redis::Alfred.delete(backoff_retry_after_key) - end - - private - - def backoff_limits - max_interval = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_MINUTES', 5).to_i - max_count = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_COUNT', 10).to_i - [max_interval, (max_interval - 1) + max_count] - end - - def exhaust_backoff(new_count) - Rails.logger.warn "#{backoff_log_identifier} backoff exhausted (#{new_count} failures), prompting reauthorization" - clear_backoff! - prompt_reauthorization! - end - - def schedule_backoff_retry(new_count, max_interval, max_retries) - wait_minutes = [new_count, max_interval].min - ::Redis::Alfred.set(backoff_retry_count_key, new_count.to_s, ex: 24.hours) - ::Redis::Alfred.set(backoff_retry_after_key, wait_minutes.minutes.from_now.to_f.to_s, ex: 24.hours) - Rails.logger.warn "#{backoff_log_identifier} backoff retry #{new_count}/#{max_retries}, next attempt in #{wait_minutes}m" - end - - def backoff_retry_count_key - format(::Redis::Alfred::BACKOFF_RETRY_COUNT, obj_type: self.class.table_name.singularize, obj_id: id) - end - - def backoff_retry_after_key - format(::Redis::Alfred::BACKOFF_RETRY_AFTER, obj_type: self.class.table_name.singularize, obj_id: id) - end -end diff --git a/app/models/concerns/reauthorizable.rb b/app/models/concerns/reauthorizable.rb index b25f5525e..7a09f6436 100644 --- a/app/models/concerns/reauthorizable.rb +++ b/app/models/concerns/reauthorizable.rb @@ -13,8 +13,6 @@ module Reauthorizable extend ActiveSupport::Concern - include Backoffable - AUTHORIZATION_ERROR_THRESHOLD = 2 # model attribute @@ -67,7 +65,6 @@ module Reauthorizable def reauthorized! ::Redis::Alfred.delete(authorization_error_count_key) ::Redis::Alfred.delete(reauthorization_required_key) - clear_backoff! invalidate_inbox_cache unless instance_of?(::AutomationRule) end diff --git a/app/services/imap/authentication_error.rb b/app/services/imap/authentication_error.rb deleted file mode 100644 index 5698f5b3b..000000000 --- a/app/services/imap/authentication_error.rb +++ /dev/null @@ -1 +0,0 @@ -class Imap::AuthenticationError < StandardError; end diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb index 49e06491b..09332092c 100644 --- a/app/services/imap/base_fetch_email_service.rb +++ b/app/services/imap/base_fetch_email_service.rb @@ -107,11 +107,7 @@ class Imap::BaseFetchEmailService def build_imap_client imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true) - begin - imap.authenticate(authentication_type, channel.imap_login, imap_password) - rescue Net::IMAP::NoResponseError => e - raise Imap::AuthenticationError, e.message - end + imap.authenticate(authentication_type, channel.imap_login, imap_password) imap.select('INBOX') imap end diff --git a/config/installation_config.yml b/config/installation_config.yml index 0d22de6fc..34cb736bf 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -96,19 +96,6 @@ locked: false # ------- End of Account Related Config ------- # -# ------- Transient Error Backoff Config ------- # -- name: BACKOFF_MAX_INTERVAL_MINUTES - display_title: 'Backoff Max Interval (minutes)' - description: 'Maximum wait time in minutes between retry attempts before the backoff plateaus' - value: 5 - locked: false -- name: BACKOFF_MAX_INTERVAL_COUNT - display_title: 'Backoff Max Retry Count' - description: 'Number of additional retries at the maximum interval before prompting reauthorization' - value: 10 - locked: false -# ------- End of Transient Error Backoff Config ------- # - # ------- Email Related Config ------- # - name: MAILER_INBOUND_EMAIL_DOMAIN display_title: 'Inbound Email Domain' diff --git a/lib/exception_list.rb b/lib/exception_list.rb index dd468f07b..2fee0a170 100644 --- a/lib/exception_list.rb +++ b/lib/exception_list.rb @@ -16,10 +16,4 @@ module ExceptionList Errno::ECONNRESET, Errno::ENETUNREACH, Net::IMAP::ByeResponseError, SocketError ].freeze - - IMAP_TRANSIENT_EXCEPTIONS = (IMAP_EXCEPTIONS + [ - EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, - Net::IMAP::InvalidResponseError, Net::IMAP::ResponseParseError, - Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError - ]).freeze end diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb index fa9eeb31e..8c9361ab5 100644 --- a/lib/redis/redis_keys.rb +++ b/lib/redis/redis_keys.rb @@ -52,8 +52,4 @@ module Redis::RedisKeys ## Account Email Rate Limiting ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%d::%s'.freeze - - ## Transient Error Backoff - BACKOFF_RETRY_COUNT = 'BACKOFF:%s:%d:retry_count'.freeze - BACKOFF_RETRY_AFTER = 'BACKOFF:%s:%d:retry_after'.freeze end diff --git a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb index 1abd7450b..da4b95b15 100644 --- a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb +++ b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb @@ -38,14 +38,6 @@ RSpec.describe Inboxes::FetchImapEmailsJob do end end - context 'when channel is in backoff' do - it 'does not fetch emails' do - allow(imap_email_channel).to receive(:in_backoff?).and_return(true) - expect(Imap::FetchEmailService).not_to receive(:new) - described_class.perform_now(imap_email_channel) - end - end - context 'when the channel is regular imap' do it 'calls the imap fetch service' do fetch_service = double @@ -64,17 +56,6 @@ RSpec.describe Inboxes::FetchImapEmailsJob do described_class.perform_now(imap_email_channel, 4) expect(fetch_service).to have_received(:perform) end - - it 'clears backoff after successful fetch' do - fetch_service = double - allow(Imap::FetchEmailService).to receive(:new).and_return(fetch_service) - allow(fetch_service).to receive(:perform).and_return([]) - allow(imap_email_channel).to receive(:clear_backoff!) - - described_class.perform_now(imap_email_channel) - - expect(imap_email_channel).to have_received(:clear_backoff!) - end end context 'when the channel is Microsoft' do @@ -88,37 +69,6 @@ RSpec.describe Inboxes::FetchImapEmailsJob do end end - context 'when authentication error is raised' do - it 'calls authorization_error! on the channel' do - allow(Imap::FetchEmailService).to receive(:new).and_raise(Imap::AuthenticationError) - allow(imap_email_channel).to receive(:authorization_error!) - - described_class.perform_now(imap_email_channel) - - expect(imap_email_channel).to have_received(:authorization_error!) - end - end - - context 'when a transient IMAP error is raised' do - it 'calls apply_backoff! on the channel' do - allow(Imap::FetchEmailService).to receive(:new).and_raise(EOFError) - allow(imap_email_channel).to receive(:apply_backoff!) - - described_class.perform_now(imap_email_channel) - - expect(imap_email_channel).to have_received(:apply_backoff!) - end - end - - context 'when lock acquisition fails' do - it 'does not raise an error' do - lock_manager = instance_double(Redis::LockManager, lock: false) - allow(Redis::LockManager).to receive(:new).and_return(lock_manager) - - expect { described_class.perform_now(imap_email_channel) }.not_to raise_error - end - end - context 'when IMAP OAuth errors out' do it 'marks the connection as requiring authorization' do error_response = double diff --git a/spec/models/channel/email_spec.rb b/spec/models/channel/email_spec.rb index bdb9f859b..939e8e10a 100644 --- a/spec/models/channel/email_spec.rb +++ b/spec/models/channel/email_spec.rb @@ -2,14 +2,12 @@ require 'rails_helper' require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb' -require Rails.root.join 'spec/models/concerns/backoffable_shared.rb' RSpec.describe Channel::Email do let(:channel) { create(:channel_email) } describe 'concerns' do it_behaves_like 'reauthorizable' - it_behaves_like 'backoffable' context 'when prompt_reauthorization!' do it 'calls channel notifier mail for email' do diff --git a/spec/models/concerns/backoffable_shared.rb b/spec/models/concerns/backoffable_shared.rb deleted file mode 100644 index 385f93be7..000000000 --- a/spec/models/concerns/backoffable_shared.rb +++ /dev/null @@ -1,43 +0,0 @@ -require 'rails_helper' - -shared_examples_for 'backoffable' do - let(:obj) { FactoryBot.create(described_class.to_s.underscore.tr('/', '_').to_sym) } - - before do - allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_MINUTES', 5).and_return(2) - allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_COUNT', 10).and_return(3) - # max_interval=2, max_retries=(2-1)+3=4; exhausts on 5th apply_backoff! - end - - it 'starts with no backoff' do - expect(obj.in_backoff?).to be false - expect(obj.backoff_retry_count).to eq 0 - end - - it 'ramps backoff on each failure' do - obj.apply_backoff! - expect(obj.backoff_retry_count).to eq 1 - expect(obj.in_backoff?).to be true - end - - it 'caps wait time at max interval' do - 4.times { obj.apply_backoff! } - expect(obj.backoff_retry_count).to eq 4 - expect(obj.in_backoff?).to be true - end - - it 'exhausts backoff and calls prompt_reauthorization! after max retries' do - allow(obj).to receive(:prompt_reauthorization!) - 5.times { obj.apply_backoff! } - expect(obj).to have_received(:prompt_reauthorization!) - expect(obj.backoff_retry_count).to eq 0 - expect(obj.in_backoff?).to be false - end - - it 'clear_backoff! resets retry count and backoff window' do - obj.apply_backoff! - obj.clear_backoff! - expect(obj.in_backoff?).to be false - expect(obj.backoff_retry_count).to eq 0 - end -end From df92fd12cbe7777b5ab687cb59e52840307eab89 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 27 Feb 2026 15:31:49 +0530 Subject: [PATCH 22/24] fix: bot handoff should set waiting time (#13417) Co-authored-by: Muhsin Keloth --- app/models/conversation.rb | 1 + .../conversation/response_builder_job.rb | 8 ++-- .../conversation/response_builder_job_spec.rb | 38 +++++++++++++++++ spec/models/conversation_spec.rb | 41 +++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/app/models/conversation.rb b/app/models/conversation.rb index ca53238e8..6dd0e9df5 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -159,6 +159,7 @@ class Conversation < ApplicationRecord end def bot_handoff! + update(waiting_since: Time.current) if waiting_since.blank? open! dispatcher_dispatch(CONVERSATION_BOT_HANDOFF) end diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index 698ec56e7..c4723f6b9 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -42,10 +42,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob end def process_response - ActiveRecord::Base.transaction do - if handoff_requested? - process_action('handoff') - else + if handoff_requested? + process_action('handoff') + else + ActiveRecord::Base.transaction do create_messages Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}") account.increment_response_usage diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb index 777086613..4e48eb355 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -92,6 +92,44 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do end end + # Regression (PR #13417): wrapping create_handoff_message and bot_handoff! in the + # same transaction defers the message's after_create_commit until commit, at which + # point it clears waiting_since (bot_response). The handoff path must stay outside + # the transaction so the callback fires before bot_handoff! sets waiting_since. + context 'when handoff is requested' do + let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) } + let(:agent) { create(:user, account: account, role: :agent) } + + before do + allow(account).to receive(:feature_enabled?).and_return(false) + allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false) + allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' }) + end + + it 'sets waiting_since to approximately the handoff time' do + freeze_time do + described_class.perform_now(conversation, assistant) + + conversation.reload + expect(conversation.status).to eq('open') + expect(conversation.waiting_since).to be_within(1.second).of(Time.current) + end + end + + it 'preserves waiting_since so a human reply consumes it for reply_time tracking' do + described_class.perform_now(conversation, assistant) + + conversation.reload + expect(conversation.waiting_since).to be_present + + # A human reply clears waiting_since (consumed by dispatch_create_events + # to emit FIRST_REPLY_CREATED or REPLY_CREATED for reply_time tracking). + create(:message, conversation: conversation, message_type: :outgoing, + sender: agent, account: account, inbox: inbox) + expect(conversation.reload.waiting_since).to be_nil + end + end + context 'when message contains an image' do let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') } let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') } diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index e1883b54d..89c090207 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -313,6 +313,47 @@ RSpec.describe Conversation do end end + describe '#bot_handoff!' do + let(:conversation) { create(:conversation, status: :pending) } + + before do + allow(Rails.configuration.dispatcher).to receive(:dispatch) + end + + context 'when waiting_since is blank' do + before { conversation.update(waiting_since: nil) } + + it 'sets waiting_since to current time' do + freeze_time do + conversation.bot_handoff! + expect(conversation.reload.waiting_since).to eq(Time.current) + end + end + end + + context 'when waiting_since is already set' do + let(:original_time) { 1.hour.ago } + + before { conversation.update(waiting_since: original_time) } + + it 'preserves existing waiting_since' do + conversation.bot_handoff! + expect(conversation.reload.waiting_since).to be_within(1.second).of(original_time) + end + end + + it 'changes status to open' do + conversation.bot_handoff! + expect(conversation.reload.status).to eq('open') + end + + it 'dispatches CONVERSATION_BOT_HANDOFF event' do + expect(Rails.configuration.dispatcher).to receive(:dispatch) + .with(described_class::CONVERSATION_BOT_HANDOFF, anything, hash_including(conversation: conversation)) + conversation.bot_handoff! + end + end + describe '#toggle_priority' do it 'defaults priority to nil when created' do conversation = create(:conversation, status: 'open') From 14b4c83dc654cb3b6ac1f4cbf6d479bcaa00d165 Mon Sep 17 00:00:00 2001 From: eloijrseganfredo Date: Fri, 27 Feb 2026 07:12:03 -0300 Subject: [PATCH 23/24] fix: Prevent AudioTranscriptionJob from crashing on OpenAI 401 error (#13653) Describe the bug In v4.8.0, when an audio message is received, the system enqueues Messages::AudioTranscriptionJob even if OpenAI and Captain are disabled. This causes a Faraday::UnauthorizedError (401) which crashes the Sidekiq job and breaks the pipeline for that message. To Reproduce Disable OpenAI/Captain integrations. Send an audio message to an inbox. Check Sidekiq logs and observe the 401 crash in AudioTranscriptionService. What this PR does Adds a rescue Faraday::UnauthorizedError block inside AudioTranscriptionService#perform. Instead of crashing the worker, it logs a warning and gracefully exits, allowing the job to complete successfully. Note: This fixes the backend crash. However, there is still a frontend reactivity issue where the audio player UI requires an F5 to load the media, which has been reported in Issue #11013. --------- Co-authored-by: Eloi Junior Seganfredo Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Co-authored-by: Muhsin Keloth --- .../app/services/messages/audio_transcription_service.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index 4aa156f47..0e574cb03 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -19,6 +19,9 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService transcriptions = transcribe_audio Rails.logger.info "Audio transcription successful: #{transcriptions}" { success: true, transcriptions: transcriptions } + rescue Faraday::UnauthorizedError + Rails.logger.warn('Skipping audio transcription: OpenAI configuration is invalid or disabled (401 Unauthorized).') + { error: 'OpenAI configuration is invalid or disabled (401)' } end private From c08fa631a9667a2fa9680ea64ebd4f1bfb2095f2 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:07:00 +0530 Subject: [PATCH 24/24] feat: Add temporary account setting to disable Captain auto-resolve (#13680) Add a temporary `captain_disable_auto_resolve` boolean setting on accounts to prevent Captain from resolving conversations. Guards both the scheduled resolution job and the assistant's resolve tool. --------- Co-authored-by: Claude Opus 4.6 --- app/models/account.rb | 2 ++ ...inbox_pending_conversations_resolution_job.rb | 2 ++ .../conversations_resolution_scheduler_job.rb | 1 + .../captain/tools/resolve_conversation_tool.rb | 1 + ..._pending_conversations_resolution_job_spec.rb | 11 +++++++++++ ...onversations_resolution_scheduler_job_spec.rb | 16 ++++++++++++++++ .../tools/resolve_conversation_tool_spec.rb | 11 +++++++++++ 7 files changed, 44 insertions(+) diff --git a/app/models/account.rb b/app/models/account.rb index 4816494fb..eabaa5c26 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -41,6 +41,7 @@ class Account < ApplicationRecord 'audio_transcriptions': { 'type': %w[boolean null] }, 'auto_resolve_label': { 'type': %w[string null] }, 'keep_pending_on_bot_failure': { 'type': %w[boolean null] }, + 'captain_disable_auto_resolve': { 'type': %w[boolean null] }, 'conversation_required_attributes': { 'type': %w[array null], 'items': { 'type': 'string' } @@ -90,6 +91,7 @@ class Account < ApplicationRecord store_accessor :settings, :audio_transcriptions, :auto_resolve_label store_accessor :settings, :captain_models, :captain_features store_accessor :settings, :keep_pending_on_bot_failure + store_accessor :settings, :captain_disable_auto_resolve has_many :account_users, dependent: :destroy_async has_many :agent_bot_inboxes, dependent: :destroy_async diff --git a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb index d3f1f5d96..ab9ca2ab1 100644 --- a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb +++ b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb @@ -2,6 +2,8 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob queue_as :low def perform(inbox) + return if inbox.account.captain_disable_auto_resolve + Current.executed_by = inbox.captain_assistant resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT) diff --git a/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb b/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb index 599dee96a..8b6527c93 100644 --- a/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb +++ b/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb @@ -12,6 +12,7 @@ module Enterprise::Account::ConversationsResolutionSchedulerJob inbox = captain_inbox.inbox next if inbox.email? + next if inbox.account.captain_disable_auto_resolve Captain::InboxPendingConversationsResolutionJob.perform_later( inbox diff --git a/enterprise/lib/captain/tools/resolve_conversation_tool.rb b/enterprise/lib/captain/tools/resolve_conversation_tool.rb index 0d2563a8b..5d96d3af1 100644 --- a/enterprise/lib/captain/tools/resolve_conversation_tool.rb +++ b/enterprise/lib/captain/tools/resolve_conversation_tool.rb @@ -6,6 +6,7 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool conversation = find_conversation(tool_context.state) return 'Conversation not found' unless conversation return "Conversation ##{conversation.display_id} is already resolved" if conversation.resolved? + return 'Auto-resolve is disabled for this account' if conversation.account.captain_disable_auto_resolve log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason }) diff --git a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb index 1a8a5a342..ab8f0296c 100644 --- a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb +++ b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb @@ -64,4 +64,15 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do } ) end + + it 'does not resolve conversations when auto-resolve is disabled at execution time' do + inbox.account.update!(captain_disable_auto_resolve: true) + + expect do + described_class.perform_now(inbox) + end.not_to(change { resolvable_pending_conversation.reload.status }) + + expect(resolvable_pending_conversation.reload.status).to eq('pending') + expect(resolvable_pending_conversation.messages.outgoing).to be_empty + end end diff --git a/spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb b/spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb index b67877412..343100a50 100644 --- a/spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb +++ b/spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb @@ -30,6 +30,22 @@ RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do end end + context 'when account has captain_disable_auto_resolve enabled' do + let!(:regular_inbox) { create(:inbox, account: account) } + + before do + create(:captain_inbox, captain_assistant: assistant, inbox: regular_inbox) + account.update!(captain_disable_auto_resolve: true) + end + + it 'does not enqueue resolution jobs' do + expect do + described_class.perform_now + end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob) + .with(regular_inbox) + end + end + context 'when inbox has no captain enabled' do let!(:inbox_without_captain) { create(:inbox, account: create(:account)) } diff --git a/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb b/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb index f91f430e8..d5792cf78 100644 --- a/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb @@ -36,6 +36,17 @@ RSpec.describe Captain::Tools::ResolveConversationTool do end end + describe 'when auto-resolve is disabled for the account' do + before { account.update!(captain_disable_auto_resolve: true) } + + it 'does not resolve and returns a disabled message' do + result = tool.perform(tool_context, reason: 'Possible spam') + + expect(result).to eq('Auto-resolve is disabled for this account') + expect(conversation.reload).not_to be_resolved + end + end + describe 'resolving an already resolved conversation' do let(:conversation) { create(:conversation, account: account, inbox: inbox, status: :resolved) }