From bd732f1fa993fbb1de7299946e82032b8c677820 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Mon, 9 Feb 2026 17:25:11 +0530
Subject: [PATCH 001/155] fix: search faqs in account language (#13428)
# Pull Request Template
## Description
Reply suggestions uses `search_documentation`. While this is useful,
there is a subtle bug, a user's message may be in a different language
(say spanish) than the FAQs present (english).
This results in embedding search in spanish and compared against english
vectors, which results in poor retrieval and poor suggestions.
Fixes # (issue)
This PR fixes the above behaviour by making a small llm call translate
the query before searching in the search documentation tool
## Type of change
- [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.
before:
after:
test on rails console:
## 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
---
Gemfile | 2 +
Gemfile.lock | 2 +
.../captain/llm/translate_query_service.rb | 49 +++++++++++++++++++
.../tools/search_documentation_service.rb | 6 ++-
.../search_reply_documentation_service.rb | 6 ++-
lib/captain/tool_instrumentation.rb | 29 ++++++-----
.../openai/openai_prompts/reply.liquid | 2 +-
7 files changed, 80 insertions(+), 16 deletions(-)
create mode 100644 enterprise/app/services/captain/llm/translate_query_service.rb
diff --git a/Gemfile b/Gemfile
index 1ae6cf093..2023c32b1 100644
--- a/Gemfile
+++ b/Gemfile
@@ -197,6 +197,8 @@ gem 'ai-agents', '>= 0.7.0'
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
+gem 'cld3', '~> 3.7'
+
# OpenTelemetry for LLM observability
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
diff --git a/Gemfile.lock b/Gemfile.lock
index b7b7301d3..ddac60fd7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -186,6 +186,7 @@ GEM
byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
+ cld3 (3.7.0)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
@@ -1037,6 +1038,7 @@ DEPENDENCIES
bullet
bundle-audit
byebug
+ cld3 (~> 3.7)
climate_control
commonmarker
csv-safe
diff --git a/enterprise/app/services/captain/llm/translate_query_service.rb b/enterprise/app/services/captain/llm/translate_query_service.rb
new file mode 100644
index 000000000..404a44755
--- /dev/null
+++ b/enterprise/app/services/captain/llm/translate_query_service.rb
@@ -0,0 +1,49 @@
+class Captain::Llm::TranslateQueryService < Captain::BaseTaskService
+ MODEL = 'gpt-4.1-nano'.freeze
+
+ pattr_initialize [:account!]
+
+ def translate(query, target_language:)
+ return query if query_in_target_language?(query)
+
+ messages = [
+ { role: 'system', content: system_prompt(target_language) },
+ { role: 'user', content: query }
+ ]
+
+ response = make_api_call(model: MODEL, messages: messages)
+ return query if response[:error]
+
+ response[:message].strip
+ rescue StandardError => e
+ Rails.logger.warn "TranslateQueryService failed: #{e.message}, falling back to original query"
+ query
+ end
+
+ private
+
+ def event_name
+ 'translate_query'
+ end
+
+ def query_in_target_language?(query)
+ detector = CLD3::NNetLanguageIdentifier.new(0, 1000)
+ result = detector.find_language(query)
+
+ result.reliable? && result.language == account_language_code
+ rescue StandardError
+ false
+ end
+
+ def account_language_code
+ account.locale&.split('_')&.first
+ end
+
+ def system_prompt(target_language)
+ <<~SYSTEM_PROMPT_MESSAGE
+ You are a helpful assistant that translates queries from one language to another.
+ Translate the query to #{target_language}.
+ Return just the translated query, no other text.
+ SYSTEM_PROMPT_MESSAGE
+ end
+end
diff --git a/enterprise/app/services/captain/tools/search_documentation_service.rb b/enterprise/app/services/captain/tools/search_documentation_service.rb
index fbc8f4154..e4a237186 100644
--- a/enterprise/app/services/captain/tools/search_documentation_service.rb
+++ b/enterprise/app/services/captain/tools/search_documentation_service.rb
@@ -9,7 +9,11 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
def execute(query:)
Rails.logger.info { "#{self.class.name}: #{query}" }
- responses = assistant.responses.approved.search(query)
+ translated_query = Captain::Llm::TranslateQueryService
+ .new(account: assistant.account)
+ .translate(query, target_language: assistant.account.locale_english_name)
+
+ responses = assistant.responses.approved.search(translated_query)
return 'No FAQs found for the given query' if responses.empty?
diff --git a/enterprise/app/services/captain/tools/search_reply_documentation_service.rb b/enterprise/app/services/captain/tools/search_reply_documentation_service.rb
index d2c1df42f..24c3fd379 100644
--- a/enterprise/app/services/captain/tools/search_reply_documentation_service.rb
+++ b/enterprise/app/services/captain/tools/search_reply_documentation_service.rb
@@ -18,7 +18,11 @@ class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool
def execute(query:)
Rails.logger.info { "#{self.class.name}: #{query}" }
- responses = search_responses(query)
+ translated_query = Captain::Llm::TranslateQueryService
+ .new(account: @account)
+ .translate(query, target_language: @account.locale_english_name)
+
+ responses = search_responses(translated_query)
return 'No FAQs found for the given query' if responses.empty?
responses.map { |response| format_response(response) }.join
diff --git a/lib/captain/tool_instrumentation.rb b/lib/captain/tool_instrumentation.rb
index a2bacce1a..af79a3fca 100644
--- a/lib/captain/tool_instrumentation.rb
+++ b/lib/captain/tool_instrumentation.rb
@@ -1,5 +1,6 @@
module Captain::ToolInstrumentation
extend ActiveSupport::Concern
+ include Integrations::LlmInstrumentationConstants
private
@@ -10,15 +11,10 @@ module Captain::ToolInstrumentation
response = nil
executed = false
tracer.in_span(params[:span_name]) do |span|
- span.set_attribute('langfuse.user.id', params[:account_id].to_s) if params[:account_id]
- span.set_attribute('langfuse.tags', [params[:feature_name]].to_json)
- span.set_attribute('langfuse.observation.input', params[:messages].to_json)
-
+ set_tool_session_attributes(span, params)
response = yield
executed = true
-
- # Output just the message for cleaner Langfuse display
- span.set_attribute('langfuse.observation.output', response[:message] || response.to_json)
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json)
end
response
rescue StandardError => e
@@ -26,17 +22,24 @@ module Captain::ToolInstrumentation
executed ? response : yield
end
+ def set_tool_session_attributes(span, params)
+ span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
+ span.set_attribute(ATTR_LANGFUSE_SESSION_ID, "#{params[:account_id]}_#{params[:conversation_id]}") if params[:conversation_id].present?
+ span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
+ end
+
def record_generation(chat, message, model)
return unless ChatwootApp.otel_enabled?
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
tracer.in_span("llm.#{event_name}.generation") do |span|
- span.set_attribute('gen_ai.system', 'openai')
- span.set_attribute('gen_ai.request.model', model)
- span.set_attribute('gen_ai.usage.input_tokens', message.input_tokens)
- span.set_attribute('gen_ai.usage.output_tokens', message.output_tokens) if message.respond_to?(:output_tokens)
- span.set_attribute('langfuse.observation.input', format_chat_messages(chat))
- span.set_attribute('langfuse.observation.output', message.content.to_s) if message.respond_to?(:content)
+ span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
+ span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model)
+ span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens)
+ span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens)
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, format_chat_messages(chat))
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content)
end
rescue StandardError => e
Rails.logger.warn "Failed to record generation: #{e.message}"
diff --git a/lib/integrations/openai/openai_prompts/reply.liquid b/lib/integrations/openai/openai_prompts/reply.liquid
index f9b95dbdf..f8067bb01 100644
--- a/lib/integrations/openai/openai_prompts/reply.liquid
+++ b/lib/integrations/openai/openai_prompts/reply.liquid
@@ -33,7 +33,7 @@ General guidelines:
- Reply in the customer's language
{% if has_search_tool %}
-**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information.
+**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information.
**Use the search_documentation tool first** to find relevant information before composing your reply. This ensures your response is accurate and based on actual company documentation.
{% endif %}
From 6632610e78fc8eb3673beb380ce043751ac974ea Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 9 Feb 2026 16:12:52 -0800
Subject: [PATCH 002/155] chore(deps): bump faraday from 2.13.1 to 2.14.1
(#13503)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [faraday](https://github.com/lostisland/faraday) from 2.13.1 to
2.14.1.
Release notes
This release contains a security fix, we recommend all users to
upgrade as soon as possible.
A Security Advisory with more details will be posted shortly.
If the problem persists, please try again in a few minutes.
From d272a64ff7e66d4b04592d3ea635a3ebc04ecf6e Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Wed, 11 Feb 2026 11:02:38 -0800
Subject: [PATCH 012/155] fix(mailbox): handle malformed sender address headers
(#13486)
## How to reproduce
When an inbound email has malformed sender headers (for example `From:
McDonald `), mailbox
processing can raise `Mail::Field::IncompleteParseError` while resolving
sender data in `MailPresenter`.
## What changed
This PR hardens sender parsing in `MailPresenter` with a small, readable
implementation:
- Added/used a safe parser (`parse_mail_address`) that rescues
`Mail::Field::ParseError` and `Mail::Field::IncompleteParseError`.
- `sender_name` now uses the same safe parser path.
- `original_sender` now resolves candidates in order via a compact
`filter_map` flow:
- `Reply-To`
- `X-Original-Sender`
- `From`
- All three candidates are parsed as email addresses before use
(including `X-Original-Sender`), and invalid values are ignored.
- `notification_email_from_chatwoot?` now compares sender addresses
case-insensitively (`casecmp?`) to avoid case-only mismatches.
## Test coverage
Added focused presenter specs for:
- malformed `From` header returns nil sender values and does not
classify as notification sender
- malformed `Reply-To` falls back to valid `From`
- valid `X-Original-Sender` is used when present
- invalid `X-Original-Sender` falls back to valid `From`
- mixed-case sender address still matches configured
`MAILER_SENDER_EMAIL`
## How this was tested
Ran:
- `bundle exec rspec spec/presenters/mail_presenter_spec.rb`
- `bundle exec rubocop app/presenters/mail_presenter.rb
spec/presenters/mail_presenter_spec.rb`
Sentry issue:
[CHATWOOT-B9Y](https://chatwoot-p3.sentry.io/issues/7005483640/)
---
app/presenters/mail_presenter.rb | 23 +++++---
spec/presenters/mail_presenter_spec.rb | 80 ++++++++++++++++++++++++++
2 files changed, 96 insertions(+), 7 deletions(-)
diff --git a/app/presenters/mail_presenter.rb b/app/presenters/mail_presenter.rb
index 08e370cd8..62cb0ed9a 100644
--- a/app/presenters/mail_presenter.rb
+++ b/app/presenters/mail_presenter.rb
@@ -130,11 +130,15 @@ class MailPresenter < SimpleDelegator
end
def sender_name
- Mail::Address.new((@mail[:reply_to] || @mail[:from]).value).name
+ parse_mail_address((@mail[:reply_to] || @mail[:from]).value)&.name
end
def original_sender
- from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
+ [
+ @mail[:reply_to]&.value,
+ @mail['X-Original-Sender']&.value,
+ @mail[:from]&.value
+ ].filter_map { |email| parse_mail_address(email)&.address }.first
end
def headers_data
@@ -147,10 +151,6 @@ class MailPresenter < SimpleDelegator
headers.presence
end
- def from_email_address(email)
- Mail::Address.new(email).address
- end
-
def email_forwarded_for
@mail['X-Forwarded-For'].try(:value)
end
@@ -175,11 +175,20 @@ class MailPresenter < SimpleDelegator
def notification_email_from_chatwoot?
# notification emails are send via mailer sender email address. so it should match
- original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot ')).address
+ configured_sender = Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot ')).address
+ original_sender.to_s.casecmp?(configured_sender)
end
private
+ def parse_mail_address(email)
+ return if email.blank?
+
+ Mail::Address.new(email)
+ rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError
+ nil
+ end
+
def auto_submitted?
@mail['Auto-Submitted'].present? && @mail['Auto-Submitted'].value != 'no'
end
diff --git a/spec/presenters/mail_presenter_spec.rb b/spec/presenters/mail_presenter_spec.rb
index bbe51c52d..f5ed27537 100644
--- a/spec/presenters/mail_presenter_spec.rb
+++ b/spec/presenters/mail_presenter_spec.rb
@@ -178,5 +178,85 @@ RSpec.describe MailPresenter do
expect(decorated_mail.serialized_data[:auto_reply]).to be_falsey
end
end
+
+ describe 'malformed sender headers' do
+ let(:mail_with_malformed_from) do
+ Mail.new do
+ header['From'] = 'Kevin McDonald '
+ subject :header
+ body 'Hi'
+ end
+ end
+
+ let(:mail_with_malformed_reply_to) do
+ Mail.new do
+ from 'Sender '
+ to 'Inbox '
+ subject :header
+ body 'Hi'
+ header['Reply-To'] = 'Reply User '
+ to 'Inbox '
+ subject :header
+ body 'Hi'
+ header['Reply-To'] = 'Reply User '
+ end
+ end
+
+ let(:mail_with_invalid_original_sender_header) do
+ Mail.new do
+ from 'Sender '
+ to 'Inbox '
+ subject :header
+ body 'Hi'
+ header['Reply-To'] = 'Reply User '
+ to 'Inbox '
+ subject :header
+ body 'Hi'
+ end
+
+ with_modified_env MAILER_SENDER_EMAIL: 'Chatwoot ' do
+ presenter = described_class.new(mail_with_uppercase_sender)
+ expect(presenter.notification_email_from_chatwoot?).to be(true)
+ end
+ end
+ end
end
end
From c7193c791770ff8afedb573ff0476e1c87f25ebc Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Wed, 11 Feb 2026 17:05:44 -0800
Subject: [PATCH 013/155] fix(slack): handle archived channel errors in
SendOnSlackJob (#13520)
When a Slack-integrated channel is archived, posting from Chatwoot
raises `Slack::Web::Api::Errors::IsArchived` in `SendOnSlackJob`, which
retries and can end up in dead jobs. This can be reproduced by archiving
the connected Slack channel for a valid hook and creating outgoing
messages. This change adds `IsArchived` to the existing handled Slack
API rescue path in
`Integrations::Slack::SendOnSlackService#send_message`, so
archived-channel failures now follow the same flow as related Slack
failures (`prompt_reauthorization!` + `disable`) instead of bubbling and
retrying repeatedly. I tested this by running `bundle exec rubocop
lib/integrations/slack/send_on_slack_service.rb` (with `rbenv`
initialized), and it passes with no offenses.
Sentry issue: https://chatwoot-p3.sentry.io/issues/7150427066/
---
lib/integrations/slack/send_on_slack_service.rb | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/lib/integrations/slack/send_on_slack_service.rb b/lib/integrations/slack/send_on_slack_service.rb
index 4e959c469..de6d20ed0 100644
--- a/lib/integrations/slack/send_on_slack_service.rb
+++ b/lib/integrations/slack/send_on_slack_service.rb
@@ -102,7 +102,8 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
def send_message
post_message if message_content.present?
upload_files if message.attachments.any?
- rescue Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, Slack::Web::Api::Errors::InvalidAuth,
+ rescue Slack::Web::Api::Errors::IsArchived, Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope,
+ Slack::Web::Api::Errors::InvalidAuth,
Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e
Rails.logger.error e
hook.prompt_reauthorization!
From 2c2f0547f7232fcc5c4e470b72560e79535aaa47 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Thu, 12 Feb 2026 10:07:56 +0530
Subject: [PATCH 014/155] fix: Captain not responding to campaign conversations
(#13489)
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
---
.../hook_execution_service.rb | 5 +-
.../conversation/response_builder_job.rb | 4 ++
.../hook_execution_service.rb | 4 ++
enterprise/lib/captain/tools/handoff_tool.rb | 4 ++
.../hook_execution_service_spec.rb | 71 +++++++++++++++++++
.../hook_execution_service_spec.rb | 34 +++++++++
6 files changed, 121 insertions(+), 1 deletion(-)
diff --git a/app/services/message_templates/hook_execution_service.rb b/app/services/message_templates/hook_execution_service.rb
index 6205c8f3c..93f0447f3 100644
--- a/app/services/message_templates/hook_execution_service.rb
+++ b/app/services/message_templates/hook_execution_service.rb
@@ -2,7 +2,6 @@ class MessageTemplates::HookExecutionService
pattr_initialize [:message!]
def perform
- return if conversation.campaign.present?
return if conversation.last_incoming_message.blank?
return if message.auto_reply_email?
@@ -21,6 +20,7 @@ class MessageTemplates::HookExecutionService
end
def should_send_out_of_office_message?
+ return false if conversation.campaign.present?
# should not send if its a tweet message
return false if conversation.tweet?
# should not send for outbound messages
@@ -37,6 +37,7 @@ class MessageTemplates::HookExecutionService
end
def should_send_greeting?
+ return false if conversation.campaign.present?
# should not send if its a tweet message
return false if conversation.tweet?
@@ -49,6 +50,8 @@ class MessageTemplates::HookExecutionService
# TODO: we should be able to reduce this logic once we have a toggle for email collect messages
def should_send_email_collect?
+ return false if conversation.campaign.present?
+
!contact_has_email? && inbox.web_widget? && !email_collect_was_sent?
end
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index 0f5e82e7f..297e78181 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -93,6 +93,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def send_out_of_office_message_if_applicable
+ # Campaign conversations should never receive OOO templates — the campaign itself
+ # serves as the initial outreach, and OOO would be confusing in that context.
+ return if @conversation.campaign.present?
+
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
end
diff --git a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
index 5c9a8a184..56dbc7245 100644
--- a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
+++ b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
@@ -68,6 +68,10 @@ module Enterprise::MessageTemplates::HookExecutionService
end
def send_out_of_office_message_after_handoff
+ # Campaign conversations should never receive OOO templates — the campaign itself
+ # serves as the initial outreach, and OOO would be confusing in that context.
+ return if conversation.campaign.present?
+
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
diff --git a/enterprise/lib/captain/tools/handoff_tool.rb b/enterprise/lib/captain/tools/handoff_tool.rb
index 797f248ff..d126840be 100644
--- a/enterprise/lib/captain/tools/handoff_tool.rb
+++ b/enterprise/lib/captain/tools/handoff_tool.rb
@@ -42,6 +42,10 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
end
def send_out_of_office_message_if_applicable(conversation)
+ # Campaign conversations should never receive OOO templates — the campaign itself
+ # serves as the initial outreach, and OOO would be confusing in that context.
+ return if conversation.campaign.present?
+
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
diff --git a/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb b/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb
index 9074baa57..151e9101a 100644
--- a/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb
+++ b/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb
@@ -238,6 +238,77 @@ RSpec.describe MessageTemplates::HookExecutionService do
end
end
+ context 'when conversation has a campaign' do
+ let(:campaign) { create(:campaign, account: account) }
+ let(:campaign_conversation) { create(:conversation, inbox: inbox, account: account, contact: contact, status: :pending, campaign: campaign) }
+
+ it 'schedules captain response job for incoming messages on pending campaign conversations' do
+ expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(campaign_conversation, assistant)
+
+ create(:message, conversation: campaign_conversation, message_type: :incoming)
+ end
+
+ it 'does not send greeting template on campaign conversations' do
+ inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
+
+ greeting_service = instance_double(MessageTemplates::Template::Greeting)
+ allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
+ allow(greeting_service).to receive(:perform).and_return(true)
+
+ create(:message, conversation: campaign_conversation, message_type: :incoming)
+
+ expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
+ end
+
+ it 'does not send out of office template on campaign conversations' do
+ inbox.update!(working_hours_enabled: true, out_of_office_message: 'We are currently closed')
+ inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
+ closed_all_day: true,
+ open_all_day: false
+ )
+
+ out_of_office_service = instance_double(MessageTemplates::Template::OutOfOffice)
+ allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
+ allow(out_of_office_service).to receive(:perform).and_return(true)
+
+ create(:message, conversation: campaign_conversation, message_type: :incoming)
+
+ expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
+ end
+
+ it 'does not send email collect template on campaign conversations' do
+ contact.update!(email: nil)
+ inbox.update!(enable_email_collect: true)
+
+ email_collect_service = instance_double(MessageTemplates::Template::EmailCollect)
+ allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
+ allow(email_collect_service).to receive(:perform).and_return(true)
+
+ create(:message, conversation: campaign_conversation, message_type: :incoming)
+
+ expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
+ end
+
+ it 'does not send out of office template after handoff on campaign conversations when quota is exceeded' do
+ account.update!(
+ limits: { 'captain_responses' => 100 },
+ custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
+ )
+ inbox.update!(
+ working_hours_enabled: true,
+ out_of_office_message: 'We are currently closed'
+ )
+ inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
+ closed_all_day: true,
+ open_all_day: false
+ )
+
+ expect do
+ create(:message, conversation: campaign_conversation, message_type: :incoming)
+ end.not_to(change { campaign_conversation.messages.template.count })
+ end
+ end
+
context 'when Captain quota is exceeded and handoff happens' do
before do
account.update!(
diff --git a/spec/services/message_templates/hook_execution_service_spec.rb b/spec/services/message_templates/hook_execution_service_spec.rb
index 13a820f17..fb9437111 100644
--- a/spec/services/message_templates/hook_execution_service_spec.rb
+++ b/spec/services/message_templates/hook_execution_service_spec.rb
@@ -111,6 +111,40 @@ describe MessageTemplates::HookExecutionService do
end
end
+ context 'when conversation has a campaign' do
+ let(:campaign) { create(:campaign) }
+
+ it 'does not call ::MessageTemplates::Template::Greeting on campaign conversations' do
+ contact = create(:contact, email: nil)
+ conversation = create(:conversation, contact: contact, campaign: campaign)
+ conversation.inbox.update(greeting_enabled: true, greeting_message: 'Hi, this is a greeting message', enable_email_collect: false)
+
+ greeting_service = double
+ allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
+ allow(greeting_service).to receive(:perform).and_return(true)
+
+ create(:message, conversation: conversation)
+
+ expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
+ end
+
+ it 'does not call ::MessageTemplates::Template::OutOfOffice on campaign conversations' do
+ contact = create(:contact)
+ conversation = create(:conversation, contact: contact, campaign: campaign)
+
+ conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
+ conversation.inbox.working_hours.today.update!(closed_all_day: true)
+
+ out_of_office_service = double
+ allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
+ allow(out_of_office_service).to receive(:perform).and_return(true)
+
+ create(:message, conversation: conversation)
+
+ expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
+ end
+ end
+
context 'when message is an auto reply email' do
it 'does not call any template hooks' do
contact = create(:contact)
From 4d362da9f0e54172ada5d955f16af0be0cb4a32e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Baza=20Garcia=20Rodrigues?=
<142340792+joao-baza@users.noreply.github.com>
Date: Fri, 13 Feb 2026 04:15:40 -0400
Subject: [PATCH 015/155] fix: Prevent user enumeration on password reset
endpoint (#13528)
## Description
The current password reset endpoint returns different HTTP status codes
and messages depending on whether the email exists in the system (200
for existing emails, 404 for non-existing ones). This allows attackers
to enumerate valid email addresses via the password reset form.
## Changes
### `app/controllers/devise_overrides/passwords_controller.rb`
- Removed the `if/else` branch that returned different responses based
on email existence
- Now always returns a generic `200 OK` response with the same message
regardless of whether the email exists
- Uses safe navigation operator (`&.`) to send reset instructions only
if the user exists
### `config/locales/en.yml`
- Consolidated `reset_password_success` and `reset_password_failure`
into a single generic `reset_password` key
- New message does not reveal whether the email exists in the system
## Security Impact
- **Before**: An attacker could determine if an email was registered by
observing the HTTP status code (200 vs 404) and response message
- **After**: All requests receive the same 200 response with a generic
message, preventing user enumeration
This follows [OWASP guidelines for authentication error
messages](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#authentication-responses).
Fixes #13527
---
app/controllers/devise_overrides/passwords_controller.rb | 8 ++------
config/locales/en.yml | 3 +--
2 files changed, 3 insertions(+), 8 deletions(-)
diff --git a/app/controllers/devise_overrides/passwords_controller.rb b/app/controllers/devise_overrides/passwords_controller.rb
index 00976c3cd..c69541f6f 100644
--- a/app/controllers/devise_overrides/passwords_controller.rb
+++ b/app/controllers/devise_overrides/passwords_controller.rb
@@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
def create
@user = User.from_email(params[:email])
- if @user
- @user.send_reset_password_instructions
- build_response(I18n.t('messages.reset_password_success'), 200)
- else
- build_response(I18n.t('messages.reset_password_failure'), 404)
- end
+ @user&.send_reset_password_instructions
+ build_response(I18n.t('messages.reset_password'), 200)
end
def update
diff --git a/config/locales/en.yml b/config/locales/en.yml
index f8d5b119e..07d9b0e2f 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -41,8 +41,7 @@ en:
invalid_email: 'Please enter a valid email address'
authentication_failed: 'Authentication failed. Please check your credentials and try again.'
messages:
- reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
- reset_password_failure: Uh ho! We could not find any user with the specified email.
+ reset_password: Request for password reset is successful. A email with instructions will be sent to your email if it exists.
reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
saml_not_available: SAML authentication is not available in this installation.
From 6b7180d051a1338d66cd8f5f9939b6cb1076c0eb Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Fri, 13 Feb 2026 14:06:12 -0800
Subject: [PATCH 016/155] fix(twilio): prevent dead jobs on missing channel
lookup (#13522)
## Why
We observed `Webhooks::TwilioEventsJob` failures ending up in Sidekiq
dead jobs when Twilio callback payloads could not be mapped to a
`Channel::TwilioSms` record. In this scenario, channel lookup raised
`ActiveRecord::RecordNotFound`, which caused retries and eventual dead
jobs instead of a graceful drop.
Related Sentry issue/search:
-
https://chatwoot-p3.sentry.io/issues/?project=6382945&query=Webhooks%3A%3ATwilioEventsJob%20ActiveRecord%3A%3ARecordNotFound
## What changed
This PR keeps the existing lookup flow but makes it non-raising:
- `app/services/twilio/incoming_message_service.rb`
- `find_by!` -> `find_by` for account SID + phone lookup
- Added warning log when channel lookup misses
- `app/services/twilio/delivery_status_service.rb`
- `find_by!` -> `find_by` for account SID + phone lookup
- Added warning log when channel lookup misses
## Reproduction
Configure a Twilio webhook callback that reaches Chatwoot but does not
match an existing Twilio channel lookup path. Before this change, the
job raises `RecordNotFound` and can end up in dead jobs after retries.
After this change, the job logs the miss and exits safely.
## Testing
- `bundle exec rspec
spec/services/twilio/incoming_message_service_spec.rb
spec/services/twilio/delivery_status_service_spec.rb`
- `bundle exec rubocop app/services/twilio/incoming_message_service.rb
app/services/twilio/delivery_status_service.rb`
---
app/services/twilio/delivery_status_service.rb | 14 +++++++++++++-
app/services/twilio/incoming_message_service.rb | 15 +++++++++++++--
2 files changed, 26 insertions(+), 3 deletions(-)
diff --git a/app/services/twilio/delivery_status_service.rb b/app/services/twilio/delivery_status_service.rb
index bf8422fcd..bed390aa5 100644
--- a/app/services/twilio/delivery_status_service.rb
+++ b/app/services/twilio/delivery_status_service.rb
@@ -47,8 +47,10 @@ class Twilio::DeliveryStatusService
@twilio_channel ||= if params[:MessagingServiceSid].present?
::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid])
elsif params[:AccountSid].present? && params[:From].present?
- ::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid], phone_number: params[:From])
+ ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: params[:From])
end
+ log_channel_not_found if @twilio_channel.blank?
+ @twilio_channel
end
def message
@@ -56,4 +58,14 @@ class Twilio::DeliveryStatusService
@message ||= twilio_channel.inbox.messages.find_by(source_id: params[:MessageSid])
end
+
+ def log_channel_not_found
+ Rails.logger.warn(
+ '[TWILIO] Delivery status channel lookup failed ' \
+ "account_sid=#{params[:AccountSid]} " \
+ "from=#{params[:From]} " \
+ "messaging_service_sid=#{params[:MessagingServiceSid]} " \
+ "message_sid=#{params[:MessageSid]}"
+ )
+ end
end
diff --git a/app/services/twilio/incoming_message_service.rb b/app/services/twilio/incoming_message_service.rb
index 5d695ebb2..d67b6d515 100644
--- a/app/services/twilio/incoming_message_service.rb
+++ b/app/services/twilio/incoming_message_service.rb
@@ -26,12 +26,23 @@ class Twilio::IncomingMessageService
def twilio_channel
@twilio_channel ||= ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) if params[:MessagingServiceSid].present?
if params[:AccountSid].present? && params[:To].present?
- @twilio_channel ||= ::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid],
- phone_number: params[:To])
+ @twilio_channel ||= ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid],
+ phone_number: params[:To])
end
+ log_channel_not_found if @twilio_channel.blank?
@twilio_channel
end
+ def log_channel_not_found
+ Rails.logger.warn(
+ '[TWILIO] Incoming message channel lookup failed ' \
+ "account_sid=#{params[:AccountSid]} " \
+ "to=#{params[:To]} " \
+ "messaging_service_sid=#{params[:MessagingServiceSid]} " \
+ "sms_sid=#{params[:SmsSid]}"
+ )
+ end
+
def inbox
@inbox ||= twilio_channel.inbox
end
From fd5ac2a8a3a048b2ec062c0f75d2ecd68faaecb2 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Fri, 13 Feb 2026 16:47:25 -0800
Subject: [PATCH 017/155] fix: apply installation branding replacement in
tooltip copy (#13538)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Fix hardcoded `Chatwoot` branding in two UI tooltips using the existing
`useBranding` flow so self-hosted/white-label deployments no longer show
the wrong brand text.
## Changes
- LabelSuggestion tooltip now uses:
- `replaceInstallationName($t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'))`
- Message avatar tooltip (native app/external echo) now uses:
- `replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'))`
## Why
This follows the existing branding pattern already used in the product
and keeps behavior consistent across deployments.
## Notes
- No change to message logic or API behavior.
- `AGENTS.md` updated with a branding guidance note.
## Fixes
- Fixes https://github.com/chatwoot/chatwoot/issues/13306
- Fixes https://github.com/chatwoot/chatwoot/issues/13466
## Testing
---
AGENTS.md | 9 +++++++++
.../dashboard/components-next/message/Message.vue | 4 +++-
.../conversation/conversation/LabelSuggestion.vue | 8 ++++++--
3 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 3b1bcb024..301633d7f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,6 +4,11 @@
- **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
+- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
+- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
+- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
+ - UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
+ - CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find())"` (or call `Seeders::AccountSeeder.new(account: Account.find()).perform!` directly).
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch`
@@ -93,3 +98,7 @@ Practical checklist for any change impacting core logic or public APIs
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
+
+## Branding / White-labeling note
+
+- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue
index 0f6ab85a8..78888d1e0 100644
--- a/app/javascript/dashboard/components-next/message/Message.vue
+++ b/app/javascript/dashboard/components-next/message/Message.vue
@@ -43,6 +43,7 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue';
import MessageError from './MessageError.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
+import { useBranding } from 'shared/composables/useBranding';
/**
* @typedef {Object} Attachment
@@ -143,6 +144,7 @@ const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
+const { replaceInstallationName } = useBranding();
/**
* Computes the message variant based on props
@@ -472,7 +474,7 @@ const avatarInfo = computed(() => {
const avatarTooltip = computed(() => {
if (props.contentAttributes?.externalEcho) {
- return t('CONVERSATION.NATIVE_APP_ADVISORY');
+ return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
}
if (avatarInfo.value.name === '') return '';
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
diff --git a/app/javascript/dashboard/components/widgets/conversation/conversation/LabelSuggestion.vue b/app/javascript/dashboard/components/widgets/conversation/conversation/LabelSuggestion.vue
index 9075eb4ed..0c8a4fb57 100644
--- a/app/javascript/dashboard/components/widgets/conversation/conversation/LabelSuggestion.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/conversation/LabelSuggestion.vue
@@ -2,6 +2,7 @@
// components
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
+import { useBranding } from 'shared/composables/useBranding';
// composables
import { useCaptain } from 'dashboard/composables/useCaptain';
@@ -34,8 +35,9 @@ export default {
},
setup() {
const { captainTasksEnabled } = useCaptain();
+ const { replaceInstallationName } = useBranding();
- return { captainTasksEnabled };
+ return { captainTasksEnabled, replaceInstallationName };
},
data() {
return {
@@ -228,7 +230,9 @@ export default {
Date: Mon, 16 Feb 2026 14:39:20 +0530
Subject: [PATCH 018/155] fix: Enforce team boundaries to prevent cross-team
assignments (#13353)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description
Fixes a critical bug where conversations assigned to a team could be
auto-assigned to agents outside that team when all team members were at
capacity.
## 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
---
> [!NOTE]
> **Medium Risk**
> Changes core assignment selection for both legacy and v2 flows;
misconfiguration of `allow_auto_assign` or team membership could cause
conversations to remain unassigned.
>
> **Overview**
> Prevents auto-assignment from crossing team boundaries by filtering
eligible agents to the conversation’s `team` members (and requiring
`team.allow_auto_assign`) in both the legacy `AutoAssignmentHandler`
path and the v2 `AutoAssignment::AssignmentService` (including the
Enterprise override).
>
> Adds test coverage to ensure team-scoped conversations only assign to
team members, and are skipped when team auto-assign is disabled or no
team members are available; also updates the conversations controller
spec setup to include team membership.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
67ed2bda0cd8ffd56c7e0253b86369dead2e6155. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../concerns/auto_assignment_handler.rb | 10 +++-
.../auto_assignment/assignment_service.rb | 19 ++++++--
.../auto_assignment/assignment_service.rb | 7 ++-
.../accounts/conversations_controller_spec.rb | 1 +
.../assignment_service_spec.rb | 47 +++++++++++++++++++
5 files changed, 78 insertions(+), 6 deletions(-)
diff --git a/app/models/concerns/auto_assignment_handler.rb b/app/models/concerns/auto_assignment_handler.rb
index a1198200a..dca154842 100644
--- a/app/models/concerns/auto_assignment_handler.rb
+++ b/app/models/concerns/auto_assignment_handler.rb
@@ -19,10 +19,18 @@ module AutoAssignmentHandler
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
else
# Use legacy assignment system
- AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
+ # If conversation has a team, only consider team members for assignment
+ allowed_agent_ids = team_id.present? ? team_member_ids_with_capacity : inbox.member_ids_with_assignment_capacity
+ AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: allowed_agent_ids).perform
end
end
+ def team_member_ids_with_capacity
+ return [] if team.blank? || team.allow_auto_assign.blank?
+
+ inbox.member_ids_with_assignment_capacity & team.members.ids
+ end
+
def should_run_auto_assignment?
return false unless inbox.enable_auto_assignment?
diff --git a/app/services/auto_assignment/assignment_service.rb b/app/services/auto_assignment/assignment_service.rb
index 5d75c515f..89eff9d1c 100644
--- a/app/services/auto_assignment/assignment_service.rb
+++ b/app/services/auto_assignment/assignment_service.rb
@@ -19,7 +19,7 @@ class AutoAssignment::AssignmentService
def perform_for_conversation(conversation)
return false unless assignable?(conversation)
- agent = find_available_agent
+ agent = find_available_agent(conversation)
return false unless agent
assign_conversation(conversation, agent)
@@ -44,13 +44,26 @@ class AutoAssignment::AssignmentService
scope.limit(limit)
end
- def find_available_agent
- agents = filter_agents_by_rate_limit(inbox.available_agents)
+ def find_available_agent(conversation = nil)
+ agents = filter_agents_by_team(inbox.available_agents, conversation)
+ return nil if agents.nil?
+
+ agents = filter_agents_by_rate_limit(agents)
return nil if agents.empty?
round_robin_selector.select_agent(agents)
end
+ def filter_agents_by_team(agents, conversation)
+ return agents if conversation&.team_id.blank?
+
+ team = conversation.team
+ return nil if team.blank? || team.allow_auto_assign.blank?
+
+ team_member_ids = team.members.ids
+ agents.where(user_id: team_member_ids)
+ end
+
def filter_agents_by_rate_limit(agents)
agents.select do |agent_member|
rate_limiter = build_rate_limiter(agent_member.user)
diff --git a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
index 46422f9bc..66cdc31e5 100644
--- a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
+++ b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
@@ -14,8 +14,11 @@ module Enterprise::AutoAssignment::AssignmentService
end
# Extend agent finding to add capacity checks
- def find_available_agent
- agents = filter_agents_by_rate_limit(inbox.available_agents)
+ def find_available_agent(conversation = nil)
+ agents = filter_agents_by_team(inbox.available_agents, conversation)
+ return nil if agents.nil?
+
+ agents = filter_agents_by_rate_limit(agents)
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
return nil if agents.empty?
diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
index 3c380c155..bc7b4097f 100644
--- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -330,6 +330,7 @@ RSpec.describe 'Conversations API', type: :request do
context 'when it is an authenticated user who has access to the inbox' do
before do
create(:inbox_member, user: agent, inbox: inbox)
+ create(:team_member, user: agent, team: team)
end
it 'creates a new conversation' do
diff --git a/spec/services/auto_assignment/assignment_service_spec.rb b/spec/services/auto_assignment/assignment_service_spec.rb
index 2139e5e78..36a8c7816 100644
--- a/spec/services/auto_assignment/assignment_service_spec.rb
+++ b/spec/services/auto_assignment/assignment_service_spec.rb
@@ -307,5 +307,52 @@ RSpec.describe AutoAssignment::AssignmentService do
end
end
end
+
+ context 'with team assignments' do
+ let(:team) { create(:team, account: account, allow_auto_assign: true) }
+ let(:team_member) { create(:user, account: account, role: :agent, availability: :online) }
+ let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
+
+ before do
+ create(:team_member, team: team, user: team_member)
+ create(:inbox_member, inbox: inbox, user: team_member)
+
+ allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ team_member.id.to_s => 'online' })
+
+ allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
+ allow(rate_limiter).to receive(:within_limit?).and_return(true)
+ allow(rate_limiter).to receive(:track_assignment)
+
+ round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
+ allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
+ allow(round_robin_selector).to receive(:select_agent).and_return(team_member)
+ end
+
+ it 'assigns conversation with team to team member' do
+ conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil)
+
+ service.perform_bulk_assignment(limit: 1)
+
+ expect(conversation_with_team.reload.assignee).to eq(team_member)
+ end
+
+ it 'skips assignment when team has allow_auto_assign false' do
+ team.update!(allow_auto_assign: false)
+ conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil)
+
+ service.perform_bulk_assignment(limit: 1)
+
+ expect(conversation_with_team.reload.assignee).to be_nil
+ end
+
+ it 'skips assignment when no team members are available' do
+ allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
+ conversation_with_team = create(:conversation, inbox: inbox, team: team, assignee: nil)
+
+ service.perform_bulk_assignment(limit: 1)
+
+ expect(conversation_with_team.reload.assignee).to be_nil
+ end
+ end
end
end
From 9cd7c4ef89a7922b345923777586d87a1bdc5423 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Mon, 16 Feb 2026 14:47:33 +0530
Subject: [PATCH 019/155] fix: Enhance notification emails with message details
and handle failed messages (#13273)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description
Handle messages with null content properly in UI and email notifications
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## Relevant Screenshots:
## 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
---
> [!NOTE]
> **Medium Risk**
> Touches notification email templates and message rendering conditions;
mistakes could lead to missing content/attachments in emails or
incorrect UI visibility, but changes are localized and non-auth/security
related.
>
> **Overview**
> Agent notification emails for *assigned* and *participating* new
messages now include the actual message details (sender name, rendered
text when present, and attachment links) and gracefully fall back when
content is unavailable.
>
> To support this, the mailer now passes `@message` into Liquid via
`MessageDrop` (adding `attachments` URLs), and the dashboard message UI
now renders failed/external-error messages even when `content` is `null`
while tightening retry eligibility to require content or attachments
(and still within 1 day).
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
475c8cedda54eb5e806990f977faf8098d0b27d8. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: Muhsin Keloth
---
.../dashboard/components-next/message/Message.vue | 6 +++++-
.../dashboard/components-next/message/MessageError.vue | 9 +++++++--
2 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue
index 78888d1e0..66234984c 100644
--- a/app/javascript/dashboard/components-next/message/Message.vue
+++ b/app/javascript/dashboard/components-next/message/Message.vue
@@ -391,13 +391,17 @@ const shouldRenderMessage = computed(() => {
const isUnsupported = props.contentAttributes?.isUnsupported;
const isAnIntegrationMessage =
props.contentType === CONTENT_TYPES.INTEGRATIONS;
+ const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
+ const hasExternalError = !!props.contentAttributes?.externalError;
return (
hasAttachments ||
props.content ||
isEmailContentType ||
isUnsupported ||
- isAnIntegrationMessage
+ isAnIntegrationMessage ||
+ isFailedMessage ||
+ hasExternalError
);
});
diff --git a/app/javascript/dashboard/components-next/message/MessageError.vue b/app/javascript/dashboard/components-next/message/MessageError.vue
index cd17c1e3f..fe508c805 100644
--- a/app/javascript/dashboard/components-next/message/MessageError.vue
+++ b/app/javascript/dashboard/components-next/message/MessageError.vue
@@ -12,11 +12,16 @@ defineProps({
const emit = defineEmits(['retry']);
-const { orientation, status, createdAt } = useMessageContext();
+const { orientation, status, createdAt, content, attachments } =
+ useMessageContext();
const { t } = useI18n();
-const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
+const canRetry = computed(() => {
+ const hasContent = content.value !== null;
+ const hasAttachments = attachments.value && attachments.value.length > 0;
+ return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments);
+});
From 61eaa098ae5524e492a5abd70d1349f2c2078b82 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Mon, 16 Feb 2026 23:55:13 -0800
Subject: [PATCH 020/155] fix(messages): reduce audio transcription 400 retry
noise (#13487)
## Summary
This PR reduces duplicate failure noise for audio transcription jobs
that fail with permanent HTTP 400 responses, and fixes a file-format
edge case causing intermittent 400s.
Sentry issue: [CHATWOOT-99E /
6660541334](https://chatwoot-p3.sentry.io/issues/6660541334/)
## Confirmed root cause
For some attachments, the stored filename had no extension (example:
`speech`, content type `audio/mpeg`).
When the temporary transcription upload file was created without an
extension, OpenAI returned:
`Unrecognized file format` (HTTP 400).
## Scope of changes
1. `Messages::AudioTranscriptionJob`
- Keeps `discard_on Faraday::BadRequestError` to avoid retry storms on
permanent request errors.
- Adds explicit Rails warning logs for discarded jobs with
attachment/job/status context.
2. `Messages::AudioTranscriptionService`
- Keeps guaranteed temp file cleanup via `ensure`.
- Ensures temp upload files include an extension when the original
filename has none, derived from blob `content_type`.
- This addresses intermittent failures like extensionless `audio/mpeg`
files.
## Reproduction
Enable audio transcription for an account and process an audio
attachment whose stored filename has no extension (for example `speech`)
but valid audio content type (`audio/mpeg`).
Before this fix, OpenAI transcription could return HTTP 400
`Unrecognized file format` for that attachment while similar attachments
with extensions succeeded.
## Testing
Ran:
`bundle exec rubocop
enterprise/app/jobs/messages/audio_transcription_job.rb
enterprise/app/services/messages/audio_transcription_service.rb`
Result: both modified files pass lint with no offenses.
---
.../jobs/messages/audio_transcription_job.rb | 9 ++++++
.../messages/audio_transcription_service.rb | 30 +++++++++++++++----
.../audio_transcription_service_spec.rb | 24 +++++++++++++--
3 files changed, 55 insertions(+), 8 deletions(-)
diff --git a/enterprise/app/jobs/messages/audio_transcription_job.rb b/enterprise/app/jobs/messages/audio_transcription_job.rb
index ce35405c8..5daf1e160 100644
--- a/enterprise/app/jobs/messages/audio_transcription_job.rb
+++ b/enterprise/app/jobs/messages/audio_transcription_job.rb
@@ -1,6 +1,15 @@
class Messages::AudioTranscriptionJob < ApplicationJob
queue_as :low
+ discard_on Faraday::BadRequestError do |job, error|
+ log_context = {
+ attachment_id: job.arguments.first,
+ job_id: job.job_id,
+ status_code: error.response&.dig(:status)
+ }
+
+ Rails.logger.warn("Discarding audio transcription job due to bad request: #{log_context}")
+ end
retry_on ActiveStorage::FileNotFoundError, wait: 2.seconds, attempts: 3
def perform(attachment_id)
diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb
index 1676dd862..4aa156f47 100644
--- a/enterprise/app/services/messages/audio_transcription_service.rb
+++ b/enterprise/app/services/messages/audio_transcription_service.rb
@@ -31,12 +31,20 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
end
def fetch_audio_file
+ blob = attachment.file.blob
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
FileUtils.mkdir_p(temp_dir)
- temp_file_path = File.join(temp_dir, "#{attachment.file.blob.key}-#{attachment.file.filename}")
+ temp_file_name = "#{blob.key}-#{blob.filename}"
+
+ if blob.filename.extension_without_delimiter.blank?
+ extension = extension_from_content_type(blob.content_type)
+ temp_file_name = "#{temp_file_name}.#{extension}" if extension.present?
+ end
+
+ temp_file_path = File.join(temp_dir, temp_file_name)
File.open(temp_file_path, 'wb') do |file|
- attachment.file.blob.open do |blob_file|
+ blob.open do |blob_file|
IO.copy_stream(blob_file, file)
end
end
@@ -49,13 +57,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
return transcribed_text if transcribed_text.present?
temp_file_path = fetch_audio_file
-
transcribed_text = nil
File.open(temp_file_path, 'rb') do |file|
response = @client.audio.transcribe(
parameters: {
- model: 'whisper-1',
+ model: WHISPER_MODEL,
file: file,
temperature: 0.4
}
@@ -63,10 +70,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
transcribed_text = response['text']
end
- FileUtils.rm_f(temp_file_path)
-
update_transcription(transcribed_text)
transcribed_text
+ ensure
+ FileUtils.rm_f(temp_file_path) if temp_file_path.present?
end
def instrumentation_params(file_path)
@@ -90,4 +97,15 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
message.reindex
end
+
+ def extension_from_content_type(content_type)
+ subtype = content_type.to_s.downcase.split(';').first.to_s.split('/').last.to_s
+ return if subtype.blank?
+
+ {
+ 'x-m4a' => 'm4a',
+ 'x-wav' => 'wav',
+ 'x-mp3' => 'mp3'
+ }.fetch(subtype, subtype)
+ end
end
diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
index 41a4cae83..7ece2540a 100644
--- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb
+++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
@@ -8,8 +8,8 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
before do
# Create required installation configs
- create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-api-key')
- create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
+ InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_API_KEY') { |config| config.value = 'test-api-key' }
+ InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_MODEL') { |config| config.value = 'gpt-4o-mini' }
# Mock usage limits for transcription to be available
allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } })
@@ -64,4 +64,24 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
end
end
end
+
+ describe '#fetch_audio_file' do
+ let(:service) { described_class.new(attachment) }
+
+ before do
+ attachment.file.attach(
+ io: File.open(Rails.public_path.join('audio/widget/ding.mp3')),
+ filename: 'speech',
+ content_type: 'audio/mpeg'
+ )
+ end
+
+ it 'adds extension from content type when filename has no extension' do
+ temp_file_path = service.send(:fetch_audio_file)
+
+ expect(File.extname(temp_file_path)).to eq('.mpeg')
+ ensure
+ FileUtils.rm_f(temp_file_path) if temp_file_path.present?
+ end
+ end
end
From 101eca300339e804d28da508ed1216237faa81d3 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Tue, 17 Feb 2026 13:26:56 +0530
Subject: [PATCH 021/155] feat: add captain editor events (#13524)
## Description
Adds missing analytics instrumentation for the editor AI funnel so we
can measure end-to-end usage and outcome quality.
### What was added
- Captain: Editor AI menu opened
- Captain: Generation failed
- Captain: AI-assisted message sent
### Behavior covered
- Tracks AI button click + menu open from both entry points:
- top panel sparkle button
- inline editor copilot button
- Tracks generation failures (initial + follow-up stages).
- Tracks whether accepted AI content was sent as-is or edited before
send.
### Notes
- Applies to editor Captain accept/send flow
(rewrite/summarize/reply_suggestion + follow-ups).
- Does not change Copilot sidebar flow instrumentation.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
### Manual verification steps
1. Open a conversation with Captain tasks enabled.
2. Click AI button in top panel and inline editor.
3. Confirm analytics events fire for:
- AI menu opened
4. Run an AI action and force a failure scenario (or empty response
path) and confirm generation-failed event.
5. Accept AI output, then:
- send without changes -> editedBeforeSend: false
- edit then send -> editedBeforeSend: true
## 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
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
.../components/widgets/WootWriter/Editor.vue | 15 +-
.../widgets/WootWriter/ReplyTopPanel.vue | 15 +-
.../widgets/conversation/ReplyBox.vue | 106 +++++++++--
.../composables/captain/constants.js | 12 ++
.../dashboard/composables/useCaptain.js | 36 +++-
.../dashboard/composables/useCopilotReply.js | 171 ++++++++++++++----
.../helper/AnalyticsHelper/events.js | 5 +
7 files changed, 304 insertions(+), 56 deletions(-)
create mode 100644 app/javascript/dashboard/composables/captain/constants.js
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index 4e0722c25..a323d4095 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -28,7 +28,10 @@ import { useAlert } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { BUS_EVENTS } from 'shared/constants/busEvents';
-import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
+import {
+ CONVERSATION_EVENTS,
+ CAPTAIN_EVENTS,
+} from 'dashboard/helper/AnalyticsHelper/events';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
@@ -86,6 +89,7 @@ const props = defineProps({
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
+ conversationId: { type: Number, default: null },
medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
@@ -396,7 +400,14 @@ function openFileBrowser() {
}
function handleCopilotClick() {
- showSelectionMenu.value = !showSelectionMenu.value;
+ const isOpening = !showSelectionMenu.value;
+ if (isOpening) {
+ useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
+ conversationId: props.conversationId,
+ entryPoint: 'inline',
+ });
+ }
+ showSelectionMenu.value = isOpening;
}
function handleClickOutside(event) {
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue
index 0912cc698..09939f5d2 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue
@@ -2,8 +2,10 @@
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain';
+import { useTrack } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
+import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
@@ -31,6 +33,10 @@ export default {
type: Boolean,
default: false,
},
+ conversationId: {
+ type: Number,
+ default: null,
+ },
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -69,7 +75,14 @@ export default {
};
const toggleCopilotMenu = () => {
- showCopilotMenu.value = !showCopilotMenu.value;
+ const isOpening = !showCopilotMenu.value;
+ if (isOpening) {
+ useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
+ conversationId: props.conversationId,
+ entryPoint: 'top_panel',
+ });
+ }
+ showCopilotMenu.value = isOpening;
};
const handleClickOutside = () => {
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index e9d2f6d08..ee63aa711 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -41,7 +41,10 @@ import {
truncatePreviewText,
appendQuotedTextToMessage,
} from 'dashboard/helper/quotedEmailHelper';
-import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
+import {
+ CONVERSATION_EVENTS,
+ CAPTAIN_EVENTS,
+} from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
@@ -136,6 +139,7 @@ export default {
newConversationModalActive: false,
showArticleSearchPopover: false,
hasRecordedAudio: false,
+ copilotAcceptedMessages: {},
};
},
computed: {
@@ -508,6 +512,24 @@ export default {
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
},
methods: {
+ getDraftKey(
+ conversationId = this.conversationIdByRoute,
+ replyType = this.replyType
+ ) {
+ return `draft-${conversationId}-${replyType}`;
+ },
+ getCopilotAcceptedMessage(replyType = this.replyType) {
+ const key = this.getDraftKey(this.conversationIdByRoute, replyType);
+ return this.copilotAcceptedMessages[key] || '';
+ },
+ setCopilotAcceptedMessage(message, replyType = this.replyType) {
+ const key = this.getDraftKey(this.conversationIdByRoute, replyType);
+ this.copilotAcceptedMessages[key] = trimContent(message || '');
+ },
+ clearCopilotAcceptedMessage(replyType = this.replyType) {
+ const key = this.getDraftKey(this.conversationIdByRoute, replyType);
+ delete this.copilotAcceptedMessages[key];
+ },
handleInsert(article) {
const { url, title } = article;
// Removing empty lines from the title
@@ -559,7 +581,7 @@ export default {
},
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
- const key = `draft-${conversationId}-${replyType}`;
+ const key = this.getDraftKey(conversationId, replyType);
const draftToSave = trimContent(this.message || '');
this.$store.dispatch('draftMessages/set', {
@@ -574,7 +596,7 @@ export default {
},
getFromDraft() {
if (this.conversationIdByRoute) {
- const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
+ const key = this.getDraftKey();
const messageFromStore =
this.$store.getters['draftMessages/get'](key) || '';
@@ -597,7 +619,7 @@ export default {
},
removeFromDraft() {
if (this.conversationIdByRoute) {
- const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
+ const key = this.getDraftKey();
this.$store.dispatch('draftMessages/delete', { key });
}
},
@@ -708,6 +730,7 @@ export default {
return;
}
if (!this.showMentions) {
+ const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
const isOnWhatsApp =
this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel ||
@@ -717,10 +740,17 @@ export default {
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
const isOnInstagram = this.isAnInstagramChannel;
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
- this.sendMessageAsMultipleMessages(this.message);
+ this.sendMessageAsMultipleMessages(
+ this.message,
+ copilotAcceptedMessage
+ );
} else {
const messagePayload = this.getMessagePayload(this.message);
- this.sendMessage(messagePayload);
+ this.sendMessage(
+ messagePayload,
+ this.message,
+ copilotAcceptedMessage
+ );
}
if (!this.isPrivate) {
@@ -732,13 +762,53 @@ export default {
this.$emit('update:popOutReplyBox', false);
}
},
- sendMessageAsMultipleMessages(message) {
+ sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
const messages = this.getMultipleMessagesPayload(message);
messages.forEach(messagePayload => {
- this.sendMessage(messagePayload);
+ this.sendMessage(
+ messagePayload,
+ messagePayload.message || '',
+ copilotAcceptedMessage
+ );
});
},
- sendMessageAnalyticsData(isPrivate) {
+ sendMessageAnalyticsData(
+ isPrivate,
+ { editorMessage = '', copilotAcceptedMessage = '' } = {}
+ ) {
+ const normalizeForComparison = message => {
+ let normalizedMessage = message || '';
+
+ if (this.sendWithSignature && this.messageSignature && !isPrivate) {
+ const effectiveChannelType = getEffectiveChannelType(
+ this.channelType,
+ this.inbox?.medium || ''
+ );
+ normalizedMessage = removeSignature(
+ normalizedMessage,
+ this.messageSignature,
+ effectiveChannelType
+ );
+ }
+
+ return trimContent(normalizedMessage);
+ };
+
+ const normalizedAcceptedMessage = normalizeForComparison(
+ copilotAcceptedMessage
+ );
+ const normalizedEditorMessage = normalizeForComparison(editorMessage);
+
+ if (normalizedAcceptedMessage && normalizedEditorMessage) {
+ useTrack(CAPTAIN_EVENTS.AI_ASSISTED_MESSAGE_SENT, {
+ conversationId: this.conversationIdByRoute,
+ channelType: this.channelType,
+ editedBeforeSend:
+ normalizedAcceptedMessage !== normalizedEditorMessage,
+ isPrivate,
+ });
+ }
+
// Analytics data for message signature is enabled or not in channels
return isPrivate
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
@@ -772,7 +842,11 @@ export default {
this.confirmOnSendReply();
}
},
- async sendMessage(messagePayload) {
+ async sendMessage(
+ messagePayload,
+ editorMessage = '',
+ copilotAcceptedMessage = ''
+ ) {
try {
await this.$store.dispatch(
'createPendingMessageAndSend',
@@ -781,7 +855,10 @@ export default {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
this.removeFromDraft();
- this.sendMessageAnalyticsData(messagePayload.private);
+ this.sendMessageAnalyticsData(messagePayload.private, {
+ editorMessage,
+ copilotAcceptedMessage,
+ });
} catch (error) {
const errorMessage =
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
@@ -855,6 +932,7 @@ export default {
},
clearMessage() {
this.message = '';
+ this.clearCopilotAcceptedMessage();
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
const effectiveChannelType = getEffectiveChannelType(
@@ -1119,7 +1197,9 @@ export default {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
},
onSubmitCopilotReply() {
- this.message = this.copilot.accept();
+ const acceptedMessage = this.copilot.accept();
+ this.message = acceptedMessage;
+ this.setCopilotAcceptedMessage(acceptedMessage);
},
},
};
@@ -1130,6 +1210,7 @@ export default {
{
- if (error.name === 'AbortError' || error.name === 'CanceledError') {
+ if (
+ error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
+ error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
+ ) {
return;
}
const errorMessage =
@@ -78,6 +82,24 @@ export function useCaptain() {
useAlert(errorMessage);
};
+ /**
+ * Classifies API error types for downstream analytics.
+ * @param {Error} error
+ * @returns {string}
+ */
+ const getErrorType = error => {
+ if (
+ error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
+ error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
+ ) {
+ return CAPTAIN_ERROR_TYPES.ABORTED;
+ }
+ if (error.response?.status) {
+ return `${CAPTAIN_ERROR_TYPES.HTTP_PREFIX}${error.response.status}`;
+ }
+ return CAPTAIN_ERROR_TYPES.API_ERROR;
+ };
+
// === Task Methods ===
/**
* Rewrites content with a specific operation.
@@ -103,7 +125,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
- return { message: '' };
+ return { message: '', errorType: getErrorType(error) };
}
};
@@ -125,7 +147,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
- return { message: '' };
+ return { message: '', errorType: getErrorType(error) };
}
};
@@ -147,7 +169,7 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
- return { message: '' };
+ return { message: '', errorType: getErrorType(error) };
}
};
@@ -171,7 +193,11 @@ export function useCaptain() {
return { message: generatedMessage, followUpContext: updatedContext };
} catch (error) {
handleAPIError(error);
- return { message: '', followUpContext };
+ return {
+ message: '',
+ followUpContext,
+ errorType: getErrorType(error),
+ };
}
};
diff --git a/app/javascript/dashboard/composables/useCopilotReply.js b/app/javascript/dashboard/composables/useCopilotReply.js
index 492bcb43e..42f506b16 100644
--- a/app/javascript/dashboard/composables/useCopilotReply.js
+++ b/app/javascript/dashboard/composables/useCopilotReply.js
@@ -3,6 +3,10 @@ import { useCaptain } from 'dashboard/composables/useCaptain';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
+import {
+ CAPTAIN_ERROR_TYPES,
+ CAPTAIN_GENERATION_FAILURE_REASONS,
+} from 'dashboard/composables/captain/constants';
// Actions that map to REWRITE events (with operation attribute)
const REWRITE_ACTIONS = [
@@ -52,6 +56,20 @@ function buildPayload(action, conversationId, followUpCount = undefined) {
return payload;
}
+function trackGenerationFailure({
+ action,
+ conversationId,
+ followUpCount = undefined,
+ stage,
+ reason,
+}) {
+ useTrack(CAPTAIN_EVENTS.GENERATION_FAILED, {
+ ...buildPayload(action, conversationId, followUpCount),
+ stage,
+ reason,
+ });
+}
+
/**
* Composable for managing Copilot reply generation state and actions.
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
@@ -146,7 +164,8 @@ export function useCopilotReply() {
// Reset without tracking dismiss (starting new action)
reset(false);
- abortController.value = new AbortController();
+ const requestController = new AbortController();
+ abortController.value = requestController;
isGenerating.value = true;
isContentReady.value = false;
currentAction.value = action;
@@ -154,28 +173,66 @@ export function useCopilotReply() {
trackedConversationId.value = conversationId.value;
try {
- const { message: content, followUpContext: newContext } =
- await processEvent(action, data, {
- signal: abortController.value.signal,
- });
+ const {
+ message: content,
+ followUpContext: newContext,
+ errorType,
+ } = await processEvent(action, data, {
+ signal: requestController.signal,
+ });
- if (!abortController.value?.signal.aborted) {
- generatedContent.value = content;
- followUpContext.value = newContext;
- if (content) {
- showEditor.value = true;
- // Track "Used" event on successful generation
- const eventKey = `${getEventPrefix(action)}_USED`;
- useTrack(
- CAPTAIN_EVENTS[eventKey],
- buildPayload(action, trackedConversationId.value)
- );
+ if (requestController.signal.aborted) return;
+ if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
+ if (abortController.value === requestController) {
+ isGenerating.value = false;
}
- isGenerating.value = false;
+ return;
}
- } catch {
- if (!abortController.value?.signal.aborted) {
- isGenerating.value = false;
+
+ generatedContent.value = content;
+ followUpContext.value = newContext;
+ if (content) {
+ showEditor.value = true;
+ // Track "Used" event on successful generation
+ const eventKey = `${getEventPrefix(action)}_USED`;
+ useTrack(
+ CAPTAIN_EVENTS[eventKey],
+ buildPayload(action, trackedConversationId.value)
+ );
+ } else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
+ trackGenerationFailure({
+ action,
+ conversationId: trackedConversationId.value,
+ stage: 'initial',
+ reason: errorType,
+ });
+ } else {
+ trackGenerationFailure({
+ action,
+ conversationId: trackedConversationId.value,
+ stage: 'initial',
+ reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
+ });
+ }
+ isGenerating.value = false;
+ } catch (error) {
+ if (
+ requestController.signal.aborted ||
+ error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
+ error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
+ ) {
+ return;
+ }
+ trackGenerationFailure({
+ action,
+ conversationId: trackedConversationId.value,
+ stage: 'initial',
+ reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
+ });
+ isGenerating.value = false;
+ } finally {
+ if (abortController.value === requestController) {
+ abortController.value = null;
}
}
}
@@ -187,7 +244,8 @@ export function useCopilotReply() {
async function sendFollowUp(message) {
if (!followUpContext.value || !message.trim()) return;
- abortController.value = new AbortController();
+ const requestController = new AbortController();
+ abortController.value = requestController;
isGenerating.value = true;
isContentReady.value = false;
@@ -198,24 +256,65 @@ export function useCopilotReply() {
followUpCount.value += 1;
try {
- const { message: content, followUpContext: updatedContext } =
- await followUp({
- followUpContext: followUpContext.value,
- message,
- signal: abortController.value.signal,
- });
+ const {
+ message: content,
+ followUpContext: updatedContext,
+ errorType,
+ } = await followUp({
+ followUpContext: followUpContext.value,
+ message,
+ signal: requestController.signal,
+ });
- if (!abortController.value?.signal.aborted) {
- if (content) {
- generatedContent.value = content;
- followUpContext.value = updatedContext;
- showEditor.value = true;
+ if (requestController.signal.aborted) return;
+ if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
+ if (abortController.value === requestController) {
+ isGenerating.value = false;
}
- isGenerating.value = false;
+ return;
}
- } catch {
- if (!abortController.value?.signal.aborted) {
- isGenerating.value = false;
+
+ if (content) {
+ generatedContent.value = content;
+ followUpContext.value = updatedContext;
+ showEditor.value = true;
+ } else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
+ trackGenerationFailure({
+ action: currentAction.value,
+ conversationId: trackedConversationId.value,
+ followUpCount: followUpCount.value,
+ stage: 'follow_up',
+ reason: errorType,
+ });
+ } else {
+ trackGenerationFailure({
+ action: currentAction.value,
+ conversationId: trackedConversationId.value,
+ followUpCount: followUpCount.value,
+ stage: 'follow_up',
+ reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
+ });
+ }
+ isGenerating.value = false;
+ } catch (error) {
+ if (
+ requestController.signal.aborted ||
+ error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
+ error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
+ ) {
+ return;
+ }
+ trackGenerationFailure({
+ action: currentAction.value,
+ conversationId: trackedConversationId.value,
+ followUpCount: followUpCount.value,
+ stage: 'follow_up',
+ reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
+ });
+ isGenerating.value = false;
+ } finally {
+ if (abortController.value === requestController) {
+ abortController.value = null;
}
}
}
diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js
index 0b6e85d77..c9fefb129 100644
--- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js
+++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js
@@ -85,6 +85,11 @@ export const PORTALS_EVENTS = Object.freeze({
});
export const CAPTAIN_EVENTS = Object.freeze({
+ // Editor funnel events
+ EDITOR_AI_MENU_OPENED: 'Captain: Editor AI menu opened',
+ GENERATION_FAILED: 'Captain: Generation failed',
+ AI_ASSISTED_MESSAGE_SENT: 'Captain: AI-assisted message sent',
+
// Rewrite events (with operation attribute in payload)
REWRITE_USED: 'Captain: Rewrite used',
REWRITE_APPLIED: 'Captain: Rewrite applied',
From 38743836987a218aeec0ee2599df1151bc0e2de6 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Tue, 17 Feb 2026 13:28:26 +0530
Subject: [PATCH 022/155] feat: insrument captain v2 (#13439)
# Pull Request Template
## Description
Instruments captain v2
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## 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.
Local testing:
## 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
---
Gemfile | 2 +-
Gemfile.lock | 16 ++---
.../captain/assistant/agent_runner_service.rb | 61 +++++++++++++++++++
.../assistant/agent_runner_service_spec.rb | 55 +++++++++++++++++
4 files changed, 125 insertions(+), 9 deletions(-)
diff --git a/Gemfile b/Gemfile
index 2023c32b1..e6c5a5250 100644
--- a/Gemfile
+++ b/Gemfile
@@ -191,7 +191,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
-gem 'ai-agents', '>= 0.7.0'
+gem 'ai-agents'
# 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 db9b59c66..cc1f9e253 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
- ai-agents (0.7.0)
- ruby_llm (~> 1.8.2)
+ ai-agents (0.9.0)
+ ruby_llm (~> 1.9.1)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -314,7 +314,7 @@ GEM
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
- faraday-retry (2.2.1)
+ faraday-retry (2.4.0)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
@@ -540,7 +540,7 @@ GEM
net-imap
net-pop
net-smtp
- marcel (1.0.4)
+ marcel (1.1.0)
maxminddb (0.1.22)
meta_request (0.8.5)
rack-contrib (>= 1.1, < 3)
@@ -559,7 +559,7 @@ GEM
multi_json (1.15.0)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
- multipart-post (2.3.0)
+ multipart-post (2.4.1)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
@@ -825,7 +825,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
- ruby_llm (1.8.2)
+ ruby_llm (1.9.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -1004,7 +1004,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
- zeitwerk (2.6.17)
+ zeitwerk (2.7.4)
PLATFORMS
arm64-darwin-20
@@ -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 (>= 0.7.0)
+ ai-agents
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 9c4e56841..a40e096e6 100644
--- a/enterprise/app/services/captain/assistant/agent_runner_service.rb
+++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb
@@ -1,6 +1,9 @@
require 'agents'
+require 'agents/instrumentation'
class Captain::Assistant::AgentRunnerService
+ include Integrations::LlmInstrumentationConstants
+
CONVERSATION_STATE_ATTRIBUTES = %i[
id display_id inbox_id contact_id status priority
label_list custom_attributes additional_attributes
@@ -22,7 +25,9 @@ class Captain::Assistant::AgentRunnerService
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)
result = runner.run(message_to_process, context: context, max_turns: 100)
process_agent_result(result)
@@ -50,6 +55,7 @@ class Captain::Assistant::AgentRunnerService
end
{
+ session_id: "#{@assistant.account_id}_#{@conversation&.display_id}",
conversation_history: conversation_history,
state: build_state
}
@@ -124,6 +130,31 @@ class Captain::Assistant::AgentRunnerService
[assistant_agent] + scenario_agents
end
+ def install_instrumentation(runner)
+ return unless ChatwootApp.otel_enabled?
+
+ Agents::Instrumentation.install(
+ runner,
+ tracer: OpentelemetryConfig.tracer,
+ trace_name: 'llm.captain_v2',
+ span_attributes: {
+ ATTR_LANGFUSE_TAGS => ['captain_v2'].to_json
+ },
+ attribute_provider: ->(context_wrapper) { dynamic_trace_attributes(context_wrapper) }
+ )
+ end
+
+ def dynamic_trace_attributes(context_wrapper)
+ state = context_wrapper&.context&.dig(:state) || {}
+ conversation = state[:conversation] || {}
+ {
+ 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]
+ }.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]
@@ -132,6 +163,36 @@ class Captain::Assistant::AgentRunnerService
runner
end
+ def add_usage_metadata_callback(runner)
+ return runner unless ChatwootApp.otel_enabled?
+
+ handoff_tool_name = Captain::Tools::HandoffTool.new(@assistant).name
+
+ runner.on_tool_complete do |tool_name, _tool_result, context_wrapper|
+ track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
+ end
+
+ runner.on_run_complete do |_agent_name, _result, context_wrapper|
+ write_credits_used_metadata(context_wrapper)
+ end
+ runner
+ end
+
+ def track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
+ return unless context_wrapper&.context
+ return unless tool_name.to_s == handoff_tool_name
+
+ context_wrapper.context[:captain_v2_handoff_tool_called] = true
+ end
+
+ def write_credits_used_metadata(context_wrapper)
+ root_span = context_wrapper&.context&.dig(:__otel_tracing, :root_span)
+ return unless root_span
+
+ credits_used = !context_wrapper.context[:captain_v2_handoff_tool_called]
+ root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credits_used'), credits_used)
+ end
+
def add_agent_thinking_callback(runner)
runner.on_agent_thinking do |*args|
@callbacks[:on_agent_thinking].call(*args)
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 2c05860e2..04fa0f967 100644
--- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
+++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
@@ -75,6 +75,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
it 'runs agent with extracted user message and context' do
expected_context = {
+ 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' },
@@ -306,6 +307,60 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
end
end
+ describe '#add_usage_metadata_callback' do
+ it 'sets credits_used=false when handoff tool is used' do
+ service = described_class.new(assistant: assistant, conversation: conversation)
+ runner = instance_double(Agents::AgentRunner)
+ tool_complete_callback = nil
+ run_complete_callback = nil
+ span_class = Class.new do
+ def set_attribute(*); end
+ end
+ root_span = instance_double(span_class)
+ context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } })
+
+ allow(ChatwootApp).to receive(:otel_enabled?).and_return(true)
+ allow(runner).to receive(:on_tool_complete) do |&block|
+ tool_complete_callback = block
+ runner
+ end
+ allow(runner).to receive(:on_run_complete) do |&block|
+ run_complete_callback = block
+ runner
+ end
+
+ service.send(:add_usage_metadata_callback, runner)
+
+ tool_complete_callback.call(Captain::Tools::HandoffTool.new(assistant).name, 'ok', context_wrapper)
+
+ expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credits_used', false)
+ run_complete_callback.call('assistant', nil, context_wrapper)
+ end
+
+ it 'sets credits_used=true when handoff tool is not used' do
+ service = described_class.new(assistant: assistant, conversation: conversation)
+ runner = instance_double(Agents::AgentRunner)
+ run_complete_callback = nil
+ span_class = Class.new do
+ def set_attribute(*); end
+ end
+ root_span = instance_double(span_class)
+ context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } })
+
+ allow(ChatwootApp).to receive(:otel_enabled?).and_return(true)
+ allow(runner).to receive(:on_tool_complete).and_return(runner)
+ allow(runner).to receive(:on_run_complete) do |&block|
+ run_complete_callback = block
+ runner
+ end
+
+ service.send(:add_usage_metadata_callback, runner)
+
+ expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credits_used', true)
+ run_complete_callback.call('assistant', nil, context_wrapper)
+ end
+ end
+
describe 'constants' do
it 'defines conversation state attributes' do
expect(described_class::CONVERSATION_STATE_ATTRIBUTES).to include(
From aa7e3c2d382bd2696737b058983b3af72fa2853c Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Tue, 17 Feb 2026 13:30:04 +0530
Subject: [PATCH 023/155] feat: langfuse logging improvements (#13534)
Langfuse logging improvements
## Description
Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes # (issue)
For reply suggestion: the errors are being stored inside output field,
but observations should be marked as errors.
For assistant: add credit_used metadata to filter handoffs from
ai-replies
For langfuse tool call: add `observation_type=tool`
## Type of change
- [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.
before:
after:
`credit_used` to filter handoffs from AI replies that cause credit usage
set `observation_type` to `tool`
## 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
- [ ] 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
---
.../helpers/captain/chat_response_helper.rb | 23 +++++++++++++++++++
lib/captain/tool_instrumentation.rb | 9 ++++++++
lib/integrations/llm_instrumentation.rb | 3 +++
.../llm_instrumentation_constants.rb | 1 +
lib/integrations/llm_instrumentation_spans.rb | 1 +
5 files changed, 37 insertions(+)
diff --git a/enterprise/app/helpers/captain/chat_response_helper.rb b/enterprise/app/helpers/captain/chat_response_helper.rb
index e0323996f..bfb8adc11 100644
--- a/enterprise/app/helpers/captain/chat_response_helper.rb
+++ b/enterprise/app/helpers/captain/chat_response_helper.rb
@@ -1,10 +1,13 @@
module Captain::ChatResponseHelper
+ include Integrations::LlmInstrumentationConstants
+
private
def build_response(response)
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
parsed = parse_json_response(response.content)
+ apply_credit_usage_metadata(parsed)
persist_message(parsed, 'assistant')
parsed
@@ -19,6 +22,26 @@ module Captain::ChatResponseHelper
{ 'content' => content }
end
+ def apply_credit_usage_metadata(parsed_response)
+ return unless captain_v1_assistant?
+
+ OpenTelemetry::Trace.current_span.set_attribute(
+ format(ATTR_LANGFUSE_METADATA, 'credit_used'),
+ credit_used_for_response?(parsed_response).to_s
+ )
+ rescue StandardError => e
+ Rails.logger.warn "#{self.class.name} Assistant: #{@assistant.id}, Failed to set credit usage metadata: #{e.message}"
+ end
+
+ def credit_used_for_response?(parsed_response)
+ response = parsed_response['response']
+ response.present? && response != 'conversation_handoff'
+ end
+
+ def captain_v1_assistant?
+ feature_name == 'assistant' && !@assistant.account.feature_enabled?('captain_integration_v2')
+ end
+
def persist_thinking_message(tool_call)
return if @copilot_thread.blank?
diff --git a/lib/captain/tool_instrumentation.rb b/lib/captain/tool_instrumentation.rb
index af79a3fca..157aab829 100644
--- a/lib/captain/tool_instrumentation.rb
+++ b/lib/captain/tool_instrumentation.rb
@@ -15,6 +15,7 @@ module Captain::ToolInstrumentation
response = yield
executed = true
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json)
+ set_tool_session_error_attributes(span, response) if response.is_a?(Hash)
end
response
rescue StandardError => e
@@ -29,6 +30,14 @@ module Captain::ToolInstrumentation
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
end
+ def set_tool_session_error_attributes(span, response)
+ error = response[:error] || response['error']
+ return if error.blank?
+
+ span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
+ span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
+ end
+
def record_generation(chat, message, model)
return unless ChatwootApp.otel_enabled?
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
diff --git a/lib/integrations/llm_instrumentation.rb b/lib/integrations/llm_instrumentation.rb
index c3baf291e..326bb901e 100644
--- a/lib/integrations/llm_instrumentation.rb
+++ b/lib/integrations/llm_instrumentation.rb
@@ -37,6 +37,7 @@ module Integrations::LlmInstrumentation
result = yield
executed = true
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
+ set_error_attributes(span, result) if result.is_a?(Hash)
result
end
rescue StandardError => e
@@ -50,9 +51,11 @@ module Integrations::LlmInstrumentation
return yield unless ChatwootApp.otel_enabled?
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
result = yield
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
+ set_error_attributes(span, result) if result.is_a?(Hash)
result
end
end
diff --git a/lib/integrations/llm_instrumentation_constants.rb b/lib/integrations/llm_instrumentation_constants.rb
index 6ce296ee9..dfe1e7704 100644
--- a/lib/integrations/llm_instrumentation_constants.rb
+++ b/lib/integrations/llm_instrumentation_constants.rb
@@ -26,6 +26,7 @@ module Integrations::LlmInstrumentationConstants
ATTR_LANGFUSE_METADATA = 'langfuse.trace.metadata.%s'
ATTR_LANGFUSE_TRACE_INPUT = 'langfuse.trace.input'
ATTR_LANGFUSE_TRACE_OUTPUT = 'langfuse.trace.output'
+ ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type'
ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input'
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
end
diff --git a/lib/integrations/llm_instrumentation_spans.rb b/lib/integrations/llm_instrumentation_spans.rb
index 824b6aa4a..85ea599f8 100644
--- a/lib/integrations/llm_instrumentation_spans.rb
+++ b/lib/integrations/llm_instrumentation_spans.rb
@@ -39,6 +39,7 @@ module Integrations::LlmInstrumentationSpans
tool_name = tool_call.name.to_s
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
@pending_tool_spans ||= []
From cfe3061b5d7ca88738a5fa2df841dc4d3f61b9da Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Tue, 17 Feb 2026 13:30:55 +0530
Subject: [PATCH 024/155] feat: Allow removing labels via conversation context
menu (#13525)
# Pull Request Template
## Description
This PR adds support for removing labels from the conversation card
context menu. Assigned labels now show a checkmark, and clicking an
already-selected label will remove it.
Fixes
https://linear.app/chatwoot/issue/CW-6400/allow-removing-labels-directly-from-the-right-click-menu
https://github.com/chatwoot/chatwoot/issues/13367
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
**Screencast**
https://github.com/user-attachments/assets/4e3a6080-a67d-4851-9d10-d8dbf3ceeb04
## 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
- [ ] 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 | 2 ++
.../dashboard/components/ConversationItem.vue | 2 ++
.../widgets/conversation/ConversationCard.vue | 8 ++++++-
.../conversation/contextMenu/Index.vue | 17 ++++++++++++--
.../conversation/contextMenu/menuItem.vue | 10 +++++++-
.../composables/chatlist/useBulkActions.js | 23 +++++++++++++++++++
.../i18n/locale/en/conversation.json | 4 ++++
7 files changed, 62 insertions(+), 4 deletions(-)
diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue
index 08e2057e4..3b2a66929 100644
--- a/app/javascript/dashboard/components/ChatList.vue
+++ b/app/javascript/dashboard/components/ChatList.vue
@@ -145,6 +145,7 @@ const {
isConversationSelected,
onAssignAgent,
onAssignLabels,
+ onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
} = useBulkActions();
@@ -859,6 +860,7 @@ provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels);
+provide('removeLabels', onRemoveLabels);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
diff --git a/app/javascript/dashboard/components/ConversationItem.vue b/app/javascript/dashboard/components/ConversationItem.vue
index a705dd067..fcd41ad45 100644
--- a/app/javascript/dashboard/components/ConversationItem.vue
+++ b/app/javascript/dashboard/components/ConversationItem.vue
@@ -10,6 +10,7 @@ export default {
'assignAgent',
'assignTeam',
'assignLabels',
+ 'removeLabels',
'updateConversationStatus',
'toggleContextMenu',
'markAsUnread',
@@ -63,6 +64,7 @@ export default {
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
+ @remove-label="removeLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
index 5093e5c4b..9f7805b4e 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
@@ -34,6 +34,7 @@ const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
+ 'removeLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
@@ -203,7 +204,10 @@ const onAssignAgent = agent => {
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
- closeContextMenu();
+};
+
+const onRemoveLabel = label => {
+ emit('removeLabel', [label.title], [props.chat.id]);
};
const onAssignTeam = team => {
@@ -379,11 +383,13 @@ const deleteConversation = () => {
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
+ :conversation-labels="chat.labels"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
+ @remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
diff --git a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue
index a6f79500a..34009935c 100644
--- a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue
@@ -53,6 +53,10 @@ export default {
type: String,
default: null,
},
+ conversationLabels: {
+ type: Array,
+ default: () => [],
+ },
conversationUrl: {
type: String,
default: '',
@@ -70,6 +74,7 @@ export default {
'assignAgent',
'assignTeam',
'assignLabel',
+ 'removeLabel',
'deleteConversation',
'close',
],
@@ -334,8 +339,16 @@ export default {
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
- variant="label"
- @click.stop="$emit('assignLabel', label)"
+ :variant="
+ conversationLabels.includes(label.title)
+ ? 'label-assigned'
+ : 'label'
+ "
+ @click.stop="
+ conversationLabels.includes(label.title)
+ ? $emit('removeLabel', label)
+ : $emit('assignLabel', label)
+ "
/>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
+import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
option: {
@@ -22,7 +23,9 @@ defineProps({
class="flex-shrink-0"
/>
@@ -37,6 +40,11 @@ defineProps({
From e75e8a77f6ef6bdcc61e6847ed8157395f801bbc Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Tue, 17 Feb 2026 16:52:13 +0530
Subject: [PATCH 030/155] feat(shopify): Add mandatory compliance webhooks with
HMAC verification (#13549)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes
https://linear.app/chatwoot/issue/CW-6494/add-shopify-mandatory-compliance-webhooks-for-app-store-listing
Shopify requires all public apps to handle three GDPR compliance
webhooks before they can be listed on the App Store. Their automated
review checks for these endpoints and verifies that apps validate HMAC
signatures on incoming requests. We were failing both checks.
This PR adds a single webhook endpoint at `POST /webhooks/shopify` that
receives all three compliance events. When Shopify sends a webhook, it
signs the payload with our app's client secret and includes the
signature in the `X-Shopify-Hmac-SHA256` header. Our controller reads
the raw body, computes the expected HMAC-SHA256 digest, and rejects
mismatched requests with a 401.
Shopify identifies the event type through the `X-Shopify-Topic` header.
For `customers/data_request` and `customers/redact`, we simply
acknowledge with a 200—Chatwoot doesn't persist any Shopify customer
data. All order lookups happen as live API calls at query time. For
`shop/redact`, which Shopify sends after a merchant uninstalls the app,
we delete the integration hook for that shop domain and remove the
stored access token and configuration.
### How to test via Rails console
```
secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
body = '{"shop_domain":"test.myshopify.com"}'
valid_hmac = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, body))
```
#### Test 1: No HMAC → 401
```
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Topic' => 'customers/data_request' }
app.response.code # => "401"
```
#### Test 2: Invalid HMAC → 401
```
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => 'invalid', 'X-Shopify-Topic' => 'customers/data_request' }
app.response.code # => "401"
```
#### Test 3: Valid HMAC, customers/data_request → 200
```
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'customers/data_request' }
app.response.code # => "200"
```
#### Test 4: Valid HMAC, customers/redact → 200
```
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'customers/redact' }
app.response.code # => "200"
```
#### Test 5: Valid HMAC, shop/redact → 200 (deletes hook)
```
# First check if a hook exists for this domain:
Integrations::Hook.where(app_id: 'shopify', reference_id: 'test.myshopify.com').count
app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'shop/redact' }
app.response.code # => "200"
```
---------
Co-authored-by: Shivam Mishra
---
.../webhooks/shopify_controller.rb | 35 +++++++++++++++++++
config/routes.rb | 1 +
2 files changed, 36 insertions(+)
create mode 100644 app/controllers/webhooks/shopify_controller.rb
diff --git a/app/controllers/webhooks/shopify_controller.rb b/app/controllers/webhooks/shopify_controller.rb
new file mode 100644
index 000000000..efc6a5122
--- /dev/null
+++ b/app/controllers/webhooks/shopify_controller.rb
@@ -0,0 +1,35 @@
+class Webhooks::ShopifyController < ActionController::API
+ before_action :verify_hmac!
+
+ def events
+ case request.headers['X-Shopify-Topic']
+ when 'shop/redact'
+ handle_shop_redact
+ end
+
+ head :ok
+ end
+
+ private
+
+ def verify_hmac!
+ secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
+ return head :unauthorized if secret.blank?
+
+ data = request.body.read
+ request.body.rewind
+
+ hmac_header = request.headers['X-Shopify-Hmac-SHA256']
+ return head :unauthorized if hmac_header.blank?
+
+ computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data))
+ return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header)
+ end
+
+ def handle_shop_redact
+ shop_domain = params[:shop_domain]
+ return if shop_domain.blank?
+
+ Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all
+ end
+end
diff --git a/config/routes.rb b/config/routes.rb
index cab069201..38c93b91e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -564,6 +564,7 @@ Rails.application.routes.draw do
get 'webhooks/instagram', to: 'webhooks/instagram#verify'
post 'webhooks/instagram', to: 'webhooks/instagram#events'
post 'webhooks/tiktok', to: 'webhooks/tiktok#events'
+ post 'webhooks/shopify', to: 'webhooks/shopify#events'
namespace :twitter do
resource :callback, only: [:show]
From dae4f3ee13161793c73f26e0f13de77cc584dfb7 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Tue, 17 Feb 2026 18:12:14 +0530
Subject: [PATCH 031/155] fix: move llm call of captain outside transaction
(#13559)
# Pull Request Template
## Description
Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes:
The LLM call was wrapped in a transaction. This is an anti-pattern and
caused idle-connections which PG eventually terminated with
`PQconsumeInput() FATAL: terminating connection due to
idle-in-transaction timeout`
This resulted in activity messages being missing in some conversations
on captain handoff, failures queueing up for retry and captain
responding long after conversation was marked open/snoozed.
## Type of change
- [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.
locally and specs
## 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: Muhsin Keloth
---
.../conversation/response_builder_job.rb | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index 297e78181..698ec56e7 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -13,9 +13,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
if captain_v2_enabled?
generate_response_with_v2
else
- ActiveRecord::Base.transaction do
- generate_and_process_response
- end
+ generate_and_process_response
end
rescue StandardError => e
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
@@ -44,11 +42,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def process_response
- return process_action('handoff') if handoff_requested?
-
- create_messages
- Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
- account.increment_response_usage
+ ActiveRecord::Base.transaction do
+ if handoff_requested?
+ process_action('handoff')
+ else
+ create_messages
+ Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
+ account.increment_response_usage
+ end
+ end
end
def collect_previous_messages
From e8152642f2c3151c5d58847fcee60913291361a1 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Tue, 17 Feb 2026 15:35:20 -0800
Subject: [PATCH 032/155] Bump version to 4.11.0
---
VERSION_CW | 2 +-
config/app.yml | 2 +-
package.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/VERSION_CW b/VERSION_CW
index ad96464c4..a162ea75a 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.10.1
+4.11.0
diff --git a/config/app.yml b/config/app.yml
index c81f2102f..9f7a3980c 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.10.1'
+ version: '4.11.0'
development:
<<: *shared
diff --git a/package.json b/package.json
index 04821480d..b062495a0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.10.1",
+ "version": "4.11.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
From 594333a1833ca7773e7ac509cad570970bb30ab6 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 17 Feb 2026 16:12:58 -0800
Subject: [PATCH 033/155] chore(deps): bump rack from 3.2.3 to 3.2.5 (#13569)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [rack](https://github.com/rack/rack) from 3.2.3 to 3.2.5.
Changelog
All notable changes to this project will be documented in this file.
For info on how to format all future additions to this file please
reference Keep A
Changelog.
Unreleased
Security
CVE-2025-61780
Improper handling of headers in Rack::Sendfile may allow
proxy bypass.
CVE-2025-61919
Unbounded read in Rack::Request form parsing can lead to
memory exhaustion.
CVE-2026-25500
XSS injection via malicious filename in
Rack::Directory.
CVE-2026-22860
Directory traversal via root prefix bypass in
Rack::Directory.
SPEC Changes
Define rack.response_finished callback arguments more
strictly. (#2365, @skipkayhil)
Added
Add Rack::Files#assign_headers to allow overriding how
the configured file headers are set. (#2377, @codergeek121)
Add support for rack.response_finished to
Rack::TempfileReaper. (#2363, @skipkayhil)
Add support for streaming bodies when using
Rack::Events. (#2375,
@unflxw)
Add deflaters option to Rack::Deflater to
enable custom compression algorithms like zstd. (#2168, @alexanderadam)
Add Rack::Request#prefetch? for identifying requests
with Sec-Purpose: prefetch header set. (#2405, @glaszig)
Add rack.request.trusted_proxy environment key to
indicate whether the request is coming from a trusted proxy.
Rack::Deflater now uses a fixed GZip mtime value. (#2372, @bensheldon)
Multipart parser drops support for RFC 2231 filename*
parameter (prohibited by RFC 7578) and now properly handles UTF-8
encoded filenames via percent-encoding and direct UTF-8 bytes. (#2398, @wtn)
The query parser now raises
Rack::QueryParser::IncompatibleEncodingError if we try to
parse params that are not ASCII compatible. (#2416, @bquorning)
Fixed
Multipart parser: limit MIME header size check to the unread buffer
region to avoid false multipart mime part header too large
errors when previously read data accumulates in the scan buffer. (#2392, @alpaca-tc, @willnet, @krororo)
Multipart parser: limit MIME header size check to the unread buffer
region to avoid false multipart mime part header too large
errors when previously read data accumulates in the scan buffer. (#2392, @alpaca-tc, @willnet, @krororo)