\n")
+ end
+ end
end
describe '#render_markdown_to_plain_text' do
From 5d9d75496166ddc17789a5e9804039e6465f1110 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Fri, 27 Mar 2026 11:29:54 +0530
Subject: [PATCH 019/101] chore(editor): Auto-linkify URLs immediately on paste
(#13900)
# Pull Request Template
## Description
This PR upgrades the ProseMirror editor and enables automatic URL
linkification on paste. Previously, URLs were only linkified after a
user input event (e.g., typing a space). With this change, URLs are now
linkified instantly when pasted.
Fixes
https://linear.app/chatwoot/issue/CW-6682/email-channel-links-are-not-working
### https://github.com/chatwoot/prosemirror-schema/pull/42
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
**Screencast**
**Before**
https://github.com/user-attachments/assets/d38725c9-a152-4c2c-8c33-3ee717f1628f
**After**
https://github.com/user-attachments/assets/9a69a0b6-93ee-421e-896b-5a4e01a167ba
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
package.json | 2 +-
pnpm-lock.yaml | 10 +++++-----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/package.json b/package.json
index 5bde17603..c8a1b7fdf 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"@amplitude/analytics-browser": "^2.11.10",
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
- "@chatwoot/prosemirror-schema": "1.3.7",
+ "@chatwoot/prosemirror-schema": "1.3.8",
"@chatwoot/utils": "^0.0.52",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7f76cd5e2..48edce442 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,8 +26,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.3.7
- version: 1.3.7
+ specifier: 1.3.8
+ version: 1.3.8
'@chatwoot/utils':
specifier: ^0.0.52
version: 0.0.52
@@ -454,8 +454,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.3.7':
- resolution: {integrity: sha512-N+Gicecp18TSEJQoRtGZXkp8R+kC0iPSms8ezu1k8U+ySY9FAENzFQQ1rBVSSC4hDFwb9/EbSI9IFqDjHGds7g==}
+ '@chatwoot/prosemirror-schema@1.3.8':
+ resolution: {integrity: sha512-Vr8eUdydmVr7iRnNky4jXKX3XD4z5HAS4bV7zJXxA4av4ig5qjTldDOg7c/C8rqYNKGR5UEOEu9CQfGcjfKVXg==}
'@chatwoot/utils@0.0.52':
resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
@@ -4966,7 +4966,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.3.7':
+ '@chatwoot/prosemirror-schema@1.3.8':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
From 127ac0a6b2ad8410840376198fc6b494d799365e Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Fri, 27 Mar 2026 11:42:33 +0530
Subject: [PATCH 020/101] fix: show backend error message on API channel
creation failure (#13855)
---
.../routes/dashboard/settings/inbox/channels/Api.vue | 5 ++++-
app/javascript/dashboard/store/modules/inboxes.js | 3 +--
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Api.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Api.vue
index 15054fe1e..cffac463f 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Api.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Api.vue
@@ -57,7 +57,10 @@ export default {
},
});
} catch (error) {
- useAlert(this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE'));
+ useAlert(
+ error.message ||
+ this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE')
+ );
}
},
},
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index 49926f378..3f374dce2 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -220,9 +220,8 @@ export const actions = {
sendAnalyticsEvent(channel.type);
return response.data;
} catch (error) {
- const errorMessage = error?.response?.data?.message;
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
- throw new Error(errorMessage);
+ return throwErrorMessage(error);
}
},
createWebsiteChannel: async ({ commit }, params) => {
From 4381be5f3e04993f6646c5bdf6d221acf251fe36 Mon Sep 17 00:00:00 2001
From: Vishnu Narayanan
Date: Fri, 27 Mar 2026 12:18:46 +0530
Subject: [PATCH 021/101] feat: disable helpcenter on hacker plans (#12068)
This change blocks Help Center access for default/Hacker-plan accounts
and closes the downgrade gap that could leave `help_center` enabled
after a subscription falls back to the default cloud plan.
Fixes: none
Closes: none
## Why
Default-plan accounts should not be able to access the Help Center, but
the downgrade fallback path only reset the plan name and did not
reconcile premium feature flags. That meant some accounts could keep
`help_center` enabled even after landing back on the Hacker/default
plan.
## What this change does
- blocks Help Center portal and article access for default/Hacker-plan
accounts
- reconciles premium feature flags when a subscription falls back to the
default cloud plan, so `help_center` is disabled immediately instead of
waiting for a later webhook
- preserves existing account `custom_attributes` during Stripe customer
recreation instead of overwriting them
- adds Enterprise coverage for the default-plan access checks on hosted
and custom-domain Help Center routes
- fixes the public access check to use the resolved portal object so
blocked requests return the intended response instead of raising an
error
## Validation
1. Create or use an account on the default/Hacker cloud plan with an
active portal.
2. Visit the portal home page and a published article on both the
Chatwoot-hosted URL and a configured custom domain.
3. Confirm the Help Center is blocked for that account.
4. Downgrade a paid account back to the default/Hacker plan through the
Stripe webhook flow.
5. Confirm `help_center` is disabled right after the downgrade fallback
is processed and the account can no longer access the Help Center.
---------
Co-authored-by: Muhsin Keloth
Co-authored-by: Sojan Jose
---
.../api/v1/portals/articles_controller.rb | 1 +
.../api/v1/portals/categories_controller.rb | 1 +
.../public/api/v1/portals_controller.rb | 4 +-
app/controllers/public_controller.rb | 7 ++
.../public/api/v1/portals/not_active.html.erb | 12 +++
config/locales/en.yml | 4 +
.../billing/create_stripe_customer_service.rb | 40 +++++----
.../billing/handle_stripe_event_service.rb | 84 +------------------
.../reconcile_plan_features_service.rb | 61 ++++++++++++++
.../accounts/internal_attributes_service.rb | 4 +-
.../public/api/v1/portals_controller_spec.rb | 6 ++
.../api/v1/helpcenter_plan_access_spec.rb | 46 ++++++++++
.../create_stripe_customer_service_spec.rb | 74 +++++++++++-----
13 files changed, 223 insertions(+), 121 deletions(-)
create mode 100644 app/views/public/api/v1/portals/not_active.html.erb
create mode 100644 enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
create mode 100644 spec/enterprise/controllers/enterprise/public/api/v1/helpcenter_plan_access_spec.rb
diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb
index 664a1964f..a8e22d878 100644
--- a/app/controllers/public/api/v1/portals/articles_controller.rb
+++ b/app/controllers/public/api/v1/portals/articles_controller.rb
@@ -1,6 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
+ before_action :ensure_portal_feature_enabled
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show]
layout 'portal'
diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb
index ebfcb310a..3fb200269 100644
--- a/app/controllers/public/api/v1/portals/categories_controller.rb
+++ b/app/controllers/public/api/v1/portals/categories_controller.rb
@@ -1,6 +1,7 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
+ before_action :ensure_portal_feature_enabled
before_action :set_category, only: [:show]
layout 'portal'
diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb
index df4552432..a187ca8a8 100644
--- a/app/controllers/public/api/v1/portals_controller.rb
+++ b/app/controllers/public/api/v1/portals_controller.rb
@@ -1,7 +1,8 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show]
- before_action :portal
before_action :redirect_to_portal_with_locale, only: [:show]
+ before_action :portal
+ before_action :ensure_portal_feature_enabled
layout 'portal'
def show
@@ -24,6 +25,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
def redirect_to_portal_with_locale
return if params[:locale].present?
+ portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
end
diff --git a/app/controllers/public_controller.rb b/app/controllers/public_controller.rb
index 3b83a2210..b266b725b 100644
--- a/app/controllers/public_controller.rb
+++ b/app/controllers/public_controller.rb
@@ -18,4 +18,11 @@ class PublicController < ActionController::Base
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
}, status: :unauthorized and return
end
+
+ def ensure_portal_feature_enabled
+ return unless ChatwootApp.chatwoot_cloud?
+ return if @portal.account.feature_enabled?('help_center')
+
+ render 'public/api/v1/portals/not_active', status: :payment_required
+ end
end
diff --git a/app/views/public/api/v1/portals/not_active.html.erb b/app/views/public/api/v1/portals/not_active.html.erb
new file mode 100644
index 000000000..af3ecb43f
--- /dev/null
+++ b/app/views/public/api/v1/portals/not_active.html.erb
@@ -0,0 +1,12 @@
+
+
github_gist:
regex: 'https?://gist\.github\.com/(?[^/]+)/(?[a-f0-9]+)'
template: |
diff --git a/spec/config/markdown_embeds_spec.rb b/spec/config/markdown_embeds_spec.rb
index f609ac113..e9938aea0 100644
--- a/spec/config/markdown_embeds_spec.rb
+++ b/spec/config/markdown_embeds_spec.rb
@@ -21,7 +21,7 @@ describe 'Markdown Embeds Configuration' do
end
it 'contains expected embed types' do
- expected_types = %w[youtube loom vimeo mp4 arcade_tab arcade wistia bunny codepen github_gist]
+ expected_types = %w[youtube loom vimeo mp4 arcade_tab arcade wistia bunny codepen guidejar github_gist]
expect(config.keys).to match_array(expected_types)
end
end
@@ -73,6 +73,12 @@ describe 'Markdown Embeds Configuration' do
{ url: 'https://codepen.io/username/pen/abcdef', expected: { 'user' => 'username', 'pen_id' => 'abcdef' } },
{ url: 'https://www.codepen.io/testuser/pen/xyz123', expected: { 'user' => 'testuser', 'pen_id' => 'xyz123' } }
],
+ 'guidejar' => [
+ { url: 'https://www.guidejar.com/embed/i2qMQRp26rtRxpZczmaA', expected: { 'guide_id' => 'i2qMQRp26rtRxpZczmaA' } },
+ { url: 'https://guidejar.com/guides/i2qMQRp26rtRxpZczmaA', expected: { 'guide_id' => 'i2qMQRp26rtRxpZczmaA' } },
+ { url: 'https://guidejar.com/guides/d6a6fdc2-4812-4777-897e-ec1b0c64238f',
+ expected: { 'guide_id' => 'd6a6fdc2-4812-4777-897e-ec1b0c64238f' } }
+ ],
'github_gist' => [
{ url: 'https://gist.github.com/username/1234567890abcdef1234567890abcdef',
expected: { 'username' => 'username', 'gist_id' => '1234567890abcdef1234567890abcdef' } },
diff --git a/spec/lib/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb
index 1237eae2c..3415a811d 100644
--- a/spec/lib/custom_markdown_renderer_spec.rb
+++ b/spec/lib/custom_markdown_renderer_spec.rb
@@ -184,6 +184,32 @@ describe CustomMarkdownRenderer do
end
end
+ context 'when link is a GuideJar embed URL' do
+ let(:guidejar_url) { 'https://www.guidejar.com/embed/i2qMQRp26rtRxpZczmaA' }
+
+ it 'renders an iframe with GuideJar embed code' do
+ output = render_markdown_link(guidejar_url)
+ expect(output).to include('src="https://www.guidejar.com/embed/i2qMQRp26rtRxpZczmaA?type=1&controls=on"')
+ expect(output).to include('allowfullscreen')
+ end
+ end
+
+ context 'when link is a GuideJar guides URL' do
+ let(:guidejar_url) { 'https://guidejar.com/guides/d6a6fdc2-4812-4777-897e-ec1b0c64238f' }
+
+ it 'renders an iframe with GuideJar embed code' do
+ output = render_markdown_link(guidejar_url)
+ expect(output).to include('src="https://www.guidejar.com/embed/d6a6fdc2-4812-4777-897e-ec1b0c64238f?type=1&controls=on"')
+ expect(output).to include('allowfullscreen')
+ end
+
+ it 'wraps iframe in responsive container' do
+ output = render_markdown_link(guidejar_url)
+ expect(output).to include('position: relative; padding-bottom: 62.5%; height: 0;')
+ expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
+ end
+ end
+
context 'when link is a Bunny.net iframe URL' do
let(:bunny_url) { 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
From b4ce59eea8f749190980a98e84e52f7ad96a9ded Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Tue, 31 Mar 2026 10:35:50 +0530
Subject: [PATCH 029/101] feat: reclaim response_bot flag for custom_tools
(#13897)
Repurpose the deprecated response_bot feature flag slot for
custom_tools.
Migration disables the flag on any accounts that had response_bot
enabled so the repurposed slot starts in its default-off state.
Pre-deploy: run the disable script on production using the old flag name
(response_bot) before deploying this migration.
---
config/features.yml | 6 ++---
...pose_response_bot_flag_for_custom_tools.rb | 22 +++++++++++++++++++
db/schema.rb | 2 +-
3 files changed, 26 insertions(+), 4 deletions(-)
create mode 100644 db/migrate/20260324102005_repurpose_response_bot_flag_for_custom_tools.rb
diff --git a/config/features.yml b/config/features.yml
index 41515ff64..00f9321b8 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -104,10 +104,10 @@
display_name: Audit Logs
enabled: false
premium: true
-- name: response_bot
- display_name: Response Bot
+- name: custom_tools
+ display_name: Custom Tools
enabled: false
- deprecated: true
+ premium: true
- name: message_reply_to
display_name: Message Reply To
enabled: false
diff --git a/db/migrate/20260324102005_repurpose_response_bot_flag_for_custom_tools.rb b/db/migrate/20260324102005_repurpose_response_bot_flag_for_custom_tools.rb
new file mode 100644
index 000000000..d6a3199b4
--- /dev/null
+++ b/db/migrate/20260324102005_repurpose_response_bot_flag_for_custom_tools.rb
@@ -0,0 +1,22 @@
+class RepurposeResponseBotFlagForCustomTools < ActiveRecord::Migration[7.1]
+ def up
+ # The response_bot flag (deprecated) has been renamed to custom_tools.
+ # Disable it on any accounts that had response_bot enabled so the repurposed
+ # flag starts in its intended default-off state.
+ Account.feature_custom_tools.find_each(batch_size: 100) do |account|
+ account.disable_features(:custom_tools)
+ account.save!(validate: false)
+ end
+
+ # Remove the stale response_bot entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
+ # ConfigLoader only adds new flags; it never removes renamed ones.
+ # Leaving it would cause NoMethodError in enable_default_features when
+ # creating new accounts (feature_response_bot= no longer exists).
+ config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
+ return if config&.value.blank?
+
+ config.value = config.value.reject { |f| f['name'] == 'response_bot' }
+ config.save!
+ GlobalConfig.clear_cache
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 81f1dfbdd..c8af2be3e 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2026_03_20_074636) do
+ActiveRecord::Schema[7.1].define(version: 2026_03_24_102005) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
From 0012fa2c3573690390cd1c54f1e6bd770e1db005 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Tue, 31 Mar 2026 10:39:54 +0530
Subject: [PATCH 030/101] fix: align message trimming with configured maxLength
(#13947)
# Pull Request Template
## Description
This PR fixes
1. Messages being trimmed to the default 1024 limit in `trimContent`
method, instead of channel-specific limits for drafts and AI tasks.
2. Telegram messages are allowed up to 10,000 characters in config, but
the API supports only 4096, causing failures for oversized messages.
Fixes
https://linear.app/chatwoot/issue/CW-6694/captain-ai-rewrite-tasks-truncate-draft-to-1024-chars-trimcontent
https://github.com/chatwoot/chatwoot/issues/13919
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Loom video
**Before**
https://www.loom.com/share/00e9d6b4d19247febf35dffa99da3805
**After**
https://www.loom.com/share/c4900e9effc345c79bcd8a5aa1ee277b
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
.../components/widgets/conversation/ReplyBox.vue | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index dd2e7a607..ef6fa03d6 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -253,6 +253,9 @@ export default {
if (this.isAnInstagramChannel) {
return MESSAGE_MAX_LENGTH.INSTAGRAM;
}
+ if (this.isATelegramChannel) {
+ return MESSAGE_MAX_LENGTH.TELEGRAM;
+ }
if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK;
}
@@ -545,7 +548,10 @@ export default {
},
setCopilotAcceptedMessage(message, replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
- this.copilotAcceptedMessages[key] = trimContent(message || '');
+ this.copilotAcceptedMessages[key] = trimContent(
+ message || '',
+ this.maxLength
+ );
},
clearCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
@@ -603,7 +609,7 @@ export default {
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
const key = this.getDraftKey(conversationId, replyType);
- const draftToSave = trimContent(this.message || '');
+ const draftToSave = trimContent(this.message || '', this.maxLength);
this.$store.dispatch('draftMessages/set', {
key,
From 1987ac3d97e4690e94c523f87e4f9f7662a18047 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Tue, 31 Mar 2026 10:56:59 +0530
Subject: [PATCH 031/101] fix: remove bulk_auto_assignment_job cron schedule
(#13877)
---
app/jobs/inboxes/bulk_auto_assignment_job.rb | 47 ----------
config/initializers/sidekiq.rb | 17 +++-
config/schedule.yml | 8 --
.../inboxes/bulk_auto_assignment_job_spec.rb | 93 -------------------
4 files changed, 14 insertions(+), 151 deletions(-)
delete mode 100644 app/jobs/inboxes/bulk_auto_assignment_job.rb
delete mode 100644 spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb
diff --git a/app/jobs/inboxes/bulk_auto_assignment_job.rb b/app/jobs/inboxes/bulk_auto_assignment_job.rb
deleted file mode 100644
index 9e808648b..000000000
--- a/app/jobs/inboxes/bulk_auto_assignment_job.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-class Inboxes::BulkAutoAssignmentJob < ApplicationJob
- queue_as :scheduled_jobs
- include BillingHelper
-
- def perform
- Account.feature_assignment_v2.find_each do |account|
- if should_skip_auto_assignment?(account)
- Rails.logger.info("Skipping auto assignment for account #{account.id}")
- next
- end
-
- account.inboxes.where(enable_auto_assignment: true).find_each do |inbox|
- process_assignment(inbox)
- end
- end
- end
-
- private
-
- def process_assignment(inbox)
- allowed_agent_ids = inbox.member_ids_with_assignment_capacity
-
- if allowed_agent_ids.blank?
- Rails.logger.info("No agents available to assign conversation to inbox #{inbox.id}")
- return
- end
-
- assign_conversations(inbox, allowed_agent_ids)
- end
-
- def assign_conversations(inbox, allowed_agent_ids)
- unassigned_conversations = inbox.conversations.unassigned.open.limit(Limits::AUTO_ASSIGNMENT_BULK_LIMIT)
- unassigned_conversations.find_each do |conversation|
- ::AutoAssignment::AgentAssignmentService.new(
- conversation: conversation,
- allowed_agent_ids: allowed_agent_ids
- ).perform
- Rails.logger.info("Assigned conversation #{conversation.id} to agent #{allowed_agent_ids.first}")
- end
- end
-
- def should_skip_auto_assignment?(account)
- return false unless ChatwootApp.chatwoot_cloud?
-
- default_plan?(account)
- end
-end
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index 9511ae68e..7b78b466a 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -34,7 +34,18 @@ end
# https://github.com/ondrejbartas/sidekiq-cron
Rails.application.reloader.to_prepare do
- # TODO: Switch to `load_from_hash!(..., source: 'schedule')` once we have a
- # safe cleanup path for YAML-backed cron jobs already persisted in Redis.
- Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file) if File.exist?(schedule_file) && Sidekiq.server?
+ # load_from_hash! upserts jobs from the YAML and removes any Redis-persisted
+ # jobs that share the same source tag but are no longer in the file.
+ # This ensures deleted schedule entries are cleaned up on deploy.
+ if File.exist?(schedule_file) && Sidekiq.server?
+ schedule = YAML.load_file(schedule_file)
+
+ # Cron entries removed from schedule.yml but possibly still in Redis
+ # with source:'dynamic' (predating the source tag). load_from_hash!
+ # only cleans up source:'schedule' entries, so these need explicit removal.
+ # Remove names from this list once they've been through a deploy cycle.
+ %w[bulk_auto_assignment_job].each { |name| Sidekiq::Cron::Job.destroy(name) }
+
+ Sidekiq::Cron::Job.load_from_hash!(schedule, source: 'schedule')
+ end
end
diff --git a/config/schedule.yml b/config/schedule.yml
index 153724c25..f1054ad68 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -4,7 +4,6 @@
# executed daily at 0000 UTC
# schedules daily deferred jobs at stable times for each installation
-# keep the existing schedule key while the cron loader still uses load_from_hash
internal_check_new_versions_job:
cron: '0 0 * * *'
class: 'Internal::TriggerDailyScheduledItemsJob'
@@ -50,13 +49,6 @@ delete_accounts_job:
class: 'Internal::DeleteAccountsJob'
queue: scheduled_jobs
-# executed every 15 minutes
-# to assign unassigned conversations for all inboxes
-bulk_auto_assignment_job:
- cron: '*/15 * * * *'
- class: 'Inboxes::BulkAutoAssignmentJob'
- queue: scheduled_jobs
-
# executed every 30 minutes for assignment_v2
periodic_assignment_job:
cron: '*/30 * * * *'
diff --git a/spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb b/spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb
deleted file mode 100644
index 5e7e3d7cc..000000000
--- a/spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb
+++ /dev/null
@@ -1,93 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe Inboxes::BulkAutoAssignmentJob do
- let(:account) { create(:account, custom_attributes: { 'plan_name' => 'Startups' }) }
- let(:agent) { create(:user, account: account, role: :agent, auto_offline: false) }
- let(:inbox) { create(:inbox, account: account) }
- let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil, status: :open) }
- let(:assignment_service) { double }
-
- describe '#perform' do
- before do
- allow(assignment_service).to receive(:perform)
- end
-
- context 'when inbox has inbox members' do
- before do
- create(:inbox_member, user: agent, inbox: inbox)
- account.enable_features!('assignment_v2')
- inbox.update!(enable_auto_assignment: true)
- end
-
- it 'assigns unassigned conversations in enabled inboxes' do
- allow(AutoAssignment::AgentAssignmentService).to receive(:new).with(
- conversation: conversation,
- allowed_agent_ids: [agent.id]
- ).and_return(assignment_service)
-
- described_class.perform_now
- expect(AutoAssignment::AgentAssignmentService).to have_received(:new).with(
- conversation: conversation,
- allowed_agent_ids: [agent.id]
- )
- end
-
- it 'skips inboxes with auto assignment disabled' do
- inbox.update!(enable_auto_assignment: false)
- allow(AutoAssignment::AgentAssignmentService).to receive(:new)
-
- described_class.perform_now
-
- expect(AutoAssignment::AgentAssignmentService).not_to have_received(:new).with(
- conversation: conversation,
- allowed_agent_ids: [agent.id]
- )
- end
-
- context 'when account is on default plan in chatwoot cloud' do
- before do
- account.update!(custom_attributes: {})
- InstallationConfig.create(name: 'CHATWOOT_CLOUD_PLANS', value: [{ 'name' => 'default' }])
- allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- end
-
- it 'skips auto assignment' do
- allow(Rails.logger).to receive(:info)
- expect(Rails.logger).to receive(:info).with("Skipping auto assignment for account #{account.id}")
-
- allow(AutoAssignment::AgentAssignmentService).to receive(:new)
- expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
-
- described_class.perform_now
- end
- end
- end
-
- context 'when inbox has no members' do
- before do
- account.enable_features!('assignment_v2')
- inbox.update!(enable_auto_assignment: true)
- end
-
- it 'does not assign conversations' do
- allow(Rails.logger).to receive(:info)
- expect(Rails.logger).to receive(:info).with("No agents available to assign conversation to inbox #{inbox.id}")
-
- described_class.perform_now
- end
- end
-
- context 'when assignment_v2 feature is disabled' do
- before do
- account.disable_features!('assignment_v2')
- end
-
- it 'skips auto assignment' do
- allow(AutoAssignment::AgentAssignmentService).to receive(:new)
- expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
-
- described_class.perform_now
- end
- end
- end
-end
From b4b5de9b46f1ccfbacb35be6bcf5a71572843779 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Tue, 31 Mar 2026 11:10:12 +0530
Subject: [PATCH 032/101] fix: conservative hand_off prompt on auto-resolution
(#13953)
# Pull Request Template
## Description
The initial version of prompt deciding to resolve or hand-off to human
agents was too conservative especially in cases where a link or an
action was told to customer. If the customer didn't respond, Captain was
told to hand it off to the agent, but customer may actually have solved
the issue. If not, they can come back and continue the conversation.
Removed two lines about the same and now we should not see needless
handoffs.
## 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
## 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
---
enterprise/lib/captain/prompts/conversation_completion.liquid | 2 --
1 file changed, 2 deletions(-)
diff --git a/enterprise/lib/captain/prompts/conversation_completion.liquid b/enterprise/lib/captain/prompts/conversation_completion.liquid
index f6f8cd58a..ed81039af 100644
--- a/enterprise/lib/captain/prompts/conversation_completion.liquid
+++ b/enterprise/lib/captain/prompts/conversation_completion.liquid
@@ -3,8 +3,6 @@ You are evaluating whether a customer support conversation is complete and can b
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
A conversation is INCOMPLETE (keep open) if ANY of these apply:
-- The assistant suggested the customer try something or take an action — they may still be attempting it
-- The assistant directed the customer to an external resource, link, or contact — they may still be following up
- The assistant asked a question or requested information that the customer hasn't provided
- The customer asked a question that wasn't fully answered
- The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet
From 5de7ae492cd7ccac1deea0664da2e2860f403daf Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Tue, 31 Mar 2026 16:55:21 +0530
Subject: [PATCH 033/101] fix: html/body background not applied in appearance
mode (#13955)
# Pull Request Template
## Description
This PR fixes the white background bleed visible in the widget, widget
article viewer and help center when dark mode is active.
**What was happening**
While scrolling, the `` element retained a white background in
dark mode. This occurred because dark mode classes were only applied to
inner container elements, not the root.
**What changed**
* **Widget:** Updated the `useDarkMode` composable to sync the `dark`
class to `` using `watchEffect`, allowing `` to inherit dark
theme variables. Also added background styles to `html`, `body`, and
`#app` in `woot.scss`.
* **Help center portal:** Moved `bg-white dark:bg-slate-900` from
`` to `` in the portal layout so the entire page background
responds correctly to dark mode, including within the widget iframe.
* **ArticleViewer:** Replaced hardcoded `bg-white` with `bg-n-solid-1`
to ensure better theming.
Fixes
https://linear.app/chatwoot/issue/CW-6704/widget-body-colour-not-implemented
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Screencasts
### Before
**Widget**
https://github.com/user-attachments/assets/e0224ad1-81a6-440a-a824-e115fb806728
**Help center**
https://github.com/user-attachments/assets/40a8ded5-5360-474d-9ec5-fd23e037c845
### After
**Widget**
https://github.com/user-attachments/assets/dd37cc68-99fc-4d60-b2ae-cf41f9d4d38c
**Help center**
https://github.com/user-attachments/assets/bc998c4e-ef77-46fa-ac7f-4ea16d912ce3
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
app/javascript/widget/assets/scss/woot.scss | 2 +-
app/javascript/widget/composables/useDarkMode.js | 6 +++++-
app/javascript/widget/views/ArticleViewer.vue | 2 +-
app/views/layouts/portal.html.erb | 4 ++--
4 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/app/javascript/widget/assets/scss/woot.scss b/app/javascript/widget/assets/scss/woot.scss
index 07aa6a0e3..0044ccdfc 100755
--- a/app/javascript/widget/assets/scss/woot.scss
+++ b/app/javascript/widget/assets/scss/woot.scss
@@ -7,7 +7,7 @@
html,
body {
- @apply antialiased h-full;
+ @apply antialiased h-full bg-n-slate-2 dark:bg-n-solid-1;
}
.is-mobile {
diff --git a/app/javascript/widget/composables/useDarkMode.js b/app/javascript/widget/composables/useDarkMode.js
index bc19c456b..407d90980 100644
--- a/app/javascript/widget/composables/useDarkMode.js
+++ b/app/javascript/widget/composables/useDarkMode.js
@@ -1,4 +1,4 @@
-import { computed } from 'vue';
+import { computed, watchEffect } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
const isDarkModeAuto = mode => mode === 'auto';
@@ -23,6 +23,10 @@ export function useDarkMode() {
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
);
+ watchEffect(() => {
+ document.documentElement.classList.toggle('dark', prefersDarkMode.value);
+ });
+
return {
darkMode,
prefersDarkMode,
diff --git a/app/javascript/widget/views/ArticleViewer.vue b/app/javascript/widget/views/ArticleViewer.vue
index 9289d0546..bc4cf775c 100644
--- a/app/javascript/widget/views/ArticleViewer.vue
+++ b/app/javascript/widget/views/ArticleViewer.vue
@@ -10,7 +10,7 @@ export default {
-
+
diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb
index 78418881a..52d8e2789 100644
--- a/app/views/layouts/portal.html.erb
+++ b/app/views/layouts/portal.html.erb
@@ -58,9 +58,9 @@ By default, it renders:
}
-
+
-
+
<% if !@is_plain_layout_enabled %>
<%= render "public/api/v1/portals/header", portal: @portal %>
<% end %>
From 8824efe0e1767bafb007e5a946df78eab14c8bc7 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Tue, 31 Mar 2026 21:09:02 +0530
Subject: [PATCH 034/101] fix(sentry): syntaxError: No error message (#13954)
---
app/javascript/dashboard/App.vue | 4 +++-
.../routes/dashboard/settings/account/Index.vue | 13 +++++++------
app/javascript/v3/App.vue | 4 +++-
3 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue
index 8912c03d1..a706e2df5 100644
--- a/app/javascript/dashboard/App.vue
+++ b/app/javascript/dashboard/App.vue
@@ -98,7 +98,9 @@ export default {
mql.onchange = e => setColorTheme(e.matches);
},
setLocale(locale) {
- this.$root.$i18n.locale = locale;
+ if (locale) {
+ this.$root.$i18n.locale = locale;
+ }
},
async initializeAccount() {
await this.$store.dispatch('accounts/get');
diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
index 5be704c24..0502ebc1b 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
@@ -103,7 +103,10 @@ export default {
const { name, locale, id, domain, support_email, features } =
this.getAccount(this.accountId);
- this.$root.$i18n.locale = this.uiSettings?.locale || locale;
+ const effectiveLocale = this.uiSettings?.locale || locale;
+ if (effectiveLocale) {
+ this.$root.$i18n.locale = effectiveLocale;
+ }
this.name = name;
this.locale = locale;
this.id = id;
@@ -129,11 +132,9 @@ export default {
support_email: this.supportEmail,
});
// If user locale is set, update the locale with user locale
- if (this.uiSettings?.locale) {
- this.$root.$i18n.locale = this.uiSettings?.locale;
- } else {
- // If user locale is not set, update the locale with account locale
- this.$root.$i18n.locale = this.locale;
+ const updatedLocale = this.uiSettings?.locale || this.locale;
+ if (updatedLocale) {
+ this.$root.$i18n.locale = updatedLocale;
}
this.getAccount(this.id).locale = this.locale;
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
diff --git a/app/javascript/v3/App.vue b/app/javascript/v3/App.vue
index ef7107beb..c3f9b1734 100644
--- a/app/javascript/v3/App.vue
+++ b/app/javascript/v3/App.vue
@@ -35,7 +35,9 @@ export default {
};
},
setLocale(locale) {
- this.$root.$i18n.locale = locale;
+ if (locale) {
+ this.$root.$i18n.locale = locale;
+ }
},
},
};
From f2cb23d6e90c7ce00f486a02b8c6727a53648057 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Wed, 1 Apr 2026 16:55:49 +0530
Subject: [PATCH 035/101] fix: handle Socket::ResolutionError in browser push
notifications (#13957)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Linear Ticket
https://linear.app/chatwoot/issue/CW-6707/socketresolutionerror-failed-to-open-tcp-connection-to-permanently
https://linear.app/chatwoot/issue/CW-6707/socketresolutionerror-failed-to-open-tcp-connection-to-permanently#comment-14e0f9ff
## Description
Browser push notifications fail with Socket::ResolutionError when the
push subscription endpoint's domain can't be resolved via DNS (e.g.,
defunct push service, transient DNS failure). This error wasn't handled
in handle_browser_push_error, so it fell through to the catch-all else
branch and got reported to Sentry on every notification attempt — 1,637
times in the last 7 days.
The dead subscription was never cleaned up or the error suppressed, so
every subsequent notification for the affected user triggered the same
Sentry alert.
Added Socket::ResolutionError to the existing transient network error
handler alongside Errno::ECONNRESET, Net::OpenTimeout, and
Net::ReadTimeout. The error is logged but not reported to Sentry, and
the subscription is kept intact in case it's a temporary DNS blip.
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Verified that Socket::ResolutionError is a subclass of StandardError
and matches the when clause
## 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
Co-authored-by: Vishnu Narayanan
---
app/services/notification/push_notification_service.rb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/services/notification/push_notification_service.rb b/app/services/notification/push_notification_service.rb
index 125ad9113..90f835ecb 100644
--- a/app/services/notification/push_notification_service.rb
+++ b/app/services/notification/push_notification_service.rb
@@ -79,7 +79,7 @@ class Notification::PushNotificationService
subscription.destroy!
when WebPush::TooManyRequests
Rails.logger.warn "WebPush rate limited for #{user.email} on account #{notification.account.id}: #{error.message}"
- when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout
+ when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, Socket::ResolutionError
Rails.logger.error "WebPush operation error: #{error.message}"
else
ChatwootExceptionTracker.new(error, account: notification.account).capture_exception
From 4cce7f6ad89a6e3e0d967c0e6c7aae34d67fbef0 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Wed, 1 Apr 2026 15:59:12 +0400
Subject: [PATCH 036/101] fix(line): Use non-expiring URLs for image and video
messages (#13949)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Images and videos sent from Chatwoot to LINE inboxes fail to display on
the LINE mobile app — users see expired markers, broken thumbnails, or
missing images. This happens because LINE mobile lazy-loads images
rather than downloading them immediately, and the ActiveStorage signed
URLs expire after 5 minutes.
Closes
https://linear.app/chatwoot/issue/CW-6696/line-messaging-with-image-or-video-may-not-show-when-client-inactive
## How to reproduce
1. Create a LINE inbox and start a chat from the LINE mobile app
2. Close the LINE mobile app
3. Send an image from Chatwoot to that chat
4. Wait 7-8 minutes (past the 5-minute URL expiration)
5. Open the LINE mobile app — the image is broken/expired
## What changed
- **`originalContentUrl`**: switched from `download_url` (signed, 5-min
expiry) to `file_url` (permanent redirect-based URL)
- **`previewImageUrl`**: switched to `thumb_url` (250px resized
thumbnail meeting LINE's 1MB/240x240 recommendation), with fallback to
`file_url` for non-image attachments like video
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.6 (1M context)
Co-authored-by: Sojan Jose
---
app/services/line/send_on_line_service.rb | 9 +++++++--
spec/services/line/send_on_line_service_spec.rb | 16 ++++++++++------
2 files changed, 17 insertions(+), 8 deletions(-)
diff --git a/app/services/line/send_on_line_service.rb b/app/services/line/send_on_line_service.rb
index b0b6d828d..3c9d6cf17 100644
--- a/app/services/line/send_on_line_service.rb
+++ b/app/services/line/send_on_line_service.rb
@@ -44,10 +44,15 @@ class Line::SendOnLineService < Base::SendOnChannelService
# Support only image and video for now, https://developers.line.biz/en/reference/messaging-api/#image-message
next unless attachment.file_type == 'image' || attachment.file_type == 'video'
+ # Use file_url (permanent redirect-based URL) instead of download_url (signed URL that expires in 5 minutes).
+ # LINE mobile app lazy-loads images and may fetch them well after the message is sent.
+ original_url = attachment.file_url
+ preview_url = attachment.thumb_url.presence || original_url
+
{
type: attachment.file_type,
- originalContentUrl: attachment.download_url,
- previewImageUrl: attachment.download_url
+ originalContentUrl: original_url,
+ previewImageUrl: preview_url
}
end
end
diff --git a/spec/services/line/send_on_line_service_spec.rb b/spec/services/line/send_on_line_service_spec.rb
index a7520b8d8..4451a53b9 100644
--- a/spec/services/line/send_on_line_service_spec.rb
+++ b/spec/services/line/send_on_line_service_spec.rb
@@ -161,7 +161,9 @@ describe Line::SendOnLineService do
it 'sends the message with text and attachments' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
- expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png}
+ attachment.save!
+ expected_original_url_regex = %r{rails/active_storage/blobs/redirect/[a-zA-Z0-9=_\-+]+/avatar\.png}
+ expected_preview_url_regex = %r{rails/active_storage/representations/redirect/[a-zA-Z0-9=_\-+]+/[a-zA-Z0-9=_\-+]+/avatar\.png}
expect(line_client).to receive(:push_message).with(
message.conversation.contact_inbox.source_id,
@@ -169,8 +171,8 @@ describe Line::SendOnLineService do
{ type: 'text', text: message.content },
{
type: 'image',
- originalContentUrl: match(expected_url_regex),
- previewImageUrl: match(expected_url_regex)
+ originalContentUrl: match(expected_original_url_regex),
+ previewImageUrl: match(expected_preview_url_regex)
}
]
)
@@ -181,16 +183,18 @@ describe Line::SendOnLineService do
it 'sends the message with attachments only' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
+ attachment.save!
message.update!(content: nil)
- expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png}
+ expected_original_url_regex = %r{rails/active_storage/blobs/redirect/[a-zA-Z0-9=_\-+]+/avatar\.png}
+ expected_preview_url_regex = %r{rails/active_storage/representations/redirect/[a-zA-Z0-9=_\-+]+/[a-zA-Z0-9=_\-+]+/avatar\.png}
expect(line_client).to receive(:push_message).with(
message.conversation.contact_inbox.source_id,
[
{
type: 'image',
- originalContentUrl: match(expected_url_regex),
- previewImageUrl: match(expected_url_regex)
+ originalContentUrl: match(expected_original_url_regex),
+ previewImageUrl: match(expected_preview_url_regex)
}
]
)
From 65867b8b36bdeffa630d8db00e53ebd62d9af10e Mon Sep 17 00:00:00 2001
From: Vishnu Narayanan
Date: Wed, 1 Apr 2026 18:02:19 +0530
Subject: [PATCH 037/101] fix: exclude
MutexApplicationJob::LockAcquisitionError from Sentry (#13965)
## Summary
- Add `MutexApplicationJob::LockAcquisitionError` to Sentry's
`excluded_exceptions`
- This error is expected control flow (mutex lock contention during
webhook processing), not a bug
- Generated ~131K Sentry events in March 2026, 100% from
`InstagramEventsJob`
Fixes https://linear.app/chatwoot/issue/INF-58
---
config/initializers/sentry.rb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb
index ae21d7f61..eff36bfc5 100644
--- a/config/initializers/sentry.rb
+++ b/config/initializers/sentry.rb
@@ -7,7 +7,7 @@ if ENV['SENTRY_DSN'].present?
# We recommend adjusting the value in production:
config.traces_sample_rate = 0.1 if ENV['ENABLE_SENTRY_TRANSACTIONS']
- config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException']
+ config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException', 'MutexApplicationJob::LockAcquisitionError']
# to track post data in sentry
config.send_default_pii = true unless ENV['DISABLE_SENTRY_PII']
From 7b09b033ef2f82801b633dd1869fd91a3428a4b1 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Thu, 2 Apr 2026 11:02:21 +0530
Subject: [PATCH 038/101] fix: Markdown tables don't render properly in help
centre (#13971)
# Pull Request Template
## Description
This PR fixes an issue where markdown tables were not rendering
correctly in the Help Center.
The issue was caused by a backslash `(\)` being appended after table row
separators `(|)`, which breaks the markdown table parsing.
The issue was introduced after recent editor changes made to preserve
new lines, which unintentionally affected how table markdown is parsed
and displayed.
### https://github.com/chatwoot/prosemirror-schema/pull/44
Fixes
https://linear.app/chatwoot/issue/CW-6714/markdown-tables-dont-render-properly-in-help-centre-preview
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
**Before**
```
| Type | What you provide |\
|--------------|-------------------------------|\
| None | No authentication |\
| Bearer Token | A token string |\
| Basic Auth | Username and password |\
| API Key | A custom header name and value|
```
**After**
```
| Type | What you provide |
|--------------|-------------------------------|
| None | No authentication |
| Bearer Token | A token string |
| Basic Auth | Username and password |
| API Key | A custom header name and value|
```
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
package.json | 2 +-
pnpm-lock.yaml | 10 +++++-----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/package.json b/package.json
index c8a1b7fdf..ddb6c09cc 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"@amplitude/analytics-browser": "^2.11.10",
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
- "@chatwoot/prosemirror-schema": "1.3.8",
+ "@chatwoot/prosemirror-schema": "1.3.9",
"@chatwoot/utils": "^0.0.52",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 48edce442..cb4b2b148 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,8 +26,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.3.8
- version: 1.3.8
+ specifier: 1.3.9
+ version: 1.3.9
'@chatwoot/utils':
specifier: ^0.0.52
version: 0.0.52
@@ -454,8 +454,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.3.8':
- resolution: {integrity: sha512-Vr8eUdydmVr7iRnNky4jXKX3XD4z5HAS4bV7zJXxA4av4ig5qjTldDOg7c/C8rqYNKGR5UEOEu9CQfGcjfKVXg==}
+ '@chatwoot/prosemirror-schema@1.3.9':
+ resolution: {integrity: sha512-nbzvW4Rfe7EC+tHF/wWJK5pIxRzfQj/DDAtZI7pwM9uJfv9yQz6bAUCA7kz7Vq1NF29XOisZaT5W0005ygk1pg==}
'@chatwoot/utils@0.0.52':
resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
@@ -4966,7 +4966,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.3.8':
+ '@chatwoot/prosemirror-schema@1.3.9':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
From 211fb1102dd208daee414cff1b8d71ea27ac5ebf Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Thu, 2 Apr 2026 11:26:29 +0530
Subject: [PATCH 039/101] chore: rotate oauth password if unconfirmed (#13878)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When a user signs up with an email they don't own and sets a password,
that password remains valid even after the real owner later signs in via
OAuth. This means the original registrant — who never proved ownership
of the email — retains working credentials on the account. This change
closes that gap by rotating the password to a random value whenever an
unconfirmed user completes an OAuth sign-in.
The check (`oauth_user_needs_password_reset?`) is evaluated before
`skip_confirmation!` runs, since confirmation would flip `confirmed_at`
and mask the condition. If the user was unconfirmed, the stored password
is replaced with a secure random string that satisfies the password
policy. This applies to both the web and mobile OAuth callback paths, as
well as the sign-up path where the password is rotated before the reset
token is generated.
Users who lose access to password-based login as a side effect can
recover through the standard "Forgot password" flow at any time. Since
they've already proven email ownership via OAuth, this is a low-friction
recovery path
---
.../omniauth_callbacks_controller.rb | 18 ++++++++++++++++++
.../omniauth_callbacks_controller_spec.rb | 16 ++++++++++++++++
2 files changed, 34 insertions(+)
diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb
index af759af54..2c8387142 100644
--- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb
+++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb
@@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
private
def sign_in_user
+ # Capture before skip_confirmation! sets confirmed_at, which would
+ # make oauth_user_needs_password_reset? return false and skip the
+ # password reset for persisted unconfirmed users.
+ needs_password_reset = oauth_user_needs_password_reset?
@resource.skip_confirmation! if confirmable_enabled?
+ set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified
# we can just send them to the login page again with the SSO params
@@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
def sign_in_user_on_mobile
+ # See comment in sign_in_user for why this is captured before skip_confirmation!
+ needs_password_reset = oauth_user_needs_password_reset?
@resource.skip_confirmation! if confirmable_enabled?
+ set_random_password_if_oauth_user if needs_password_reset
# once the resource is found and verified
# we can just send them to the login page again with the SSO params
@@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
create_account_for_user
+ set_random_password_if_oauth_user
token = @resource.send(:set_reset_password_token)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
@@ -81,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
end
+ def oauth_user_needs_password_reset?
+ @resource.present? && (@resource.new_record? || !@resource.confirmed?)
+ end
+
+ def set_random_password_if_oauth_user
+ # Password must satisfy secure_password requirements (uppercase, lowercase, number, special char)
+ @resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted?
+ end
+
def default_devise_mapping
'user'
end
diff --git a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb
index 603458a01..35bae8e0b 100644
--- a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb
+++ b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb
@@ -164,5 +164,21 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
expect(response).to have_http_status(:ok)
end
end
+
+ it 'resets password for an unconfirmed persisted user on OAuth login' do
+ with_modified_env FRONTEND_URL: 'http://www.example.com' do
+ user = create(:user, email: 'unconfirmed-oauth@example.com', skip_confirmation: false)
+ original_password_digest = user.encrypted_password
+ set_omniauth_config('unconfirmed-oauth@example.com')
+
+ get '/omniauth/google_oauth2/callback'
+ expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
+ follow_redirect!
+
+ user.reload
+ expect(user).to be_confirmed
+ expect(user.encrypted_password).not_to eq(original_password_digest)
+ end
+ end
end
end
From 8daf6cf6cbba1246f98a59ce474b6bd633646f46 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Thu, 2 Apr 2026 12:40:11 +0530
Subject: [PATCH 040/101] feat: captain custom tools v1 (#13890)
# Pull Request Template
## Description
Adds custom tool support to v1
## 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.
## Checklist:
- [x] 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
---------
Co-authored-by: Claude Opus 4.6 (1M context)
Co-authored-by: Shivam Mishra
---
.../dashboard/api/captain/customTools.js | 6 ++
.../customTool/CustomToolCard.vue | 9 +-
.../customTool/CustomToolForm.vue | 86 +++++++++++++++++--
.../emptyStates/CustomToolsPageEmptyState.vue | 12 +++
.../components-next/sidebar/Sidebar.vue | 30 +++++--
app/javascript/dashboard/featureFlags.js | 1 +
.../i18n/locale/en/integrations.json | 10 ++-
.../dashboard/captain/captain.routes.js | 2 +-
.../routes/dashboard/captain/tools/Index.vue | 22 +++--
config/locales/en.yml | 1 +
config/routes.rb | 4 +-
.../captain/custom_tools_controller.rb | 24 +++++-
enterprise/app/models/captain/custom_tool.rb | 27 ++++--
enterprise/app/models/concerns/toolable.rb | 31 ++++---
.../policies/captain/custom_tool_policy.rb | 4 +
.../captain/llm/assistant_chat_service.rb | 22 ++++-
.../captain/llm/system_prompts_service.rb | 15 +++-
.../captain/tools/custom_http_tool.rb | 47 ++++++++++
.../reconcile_plan_features_service.rb | 2 +-
.../models/captain/_custom_tool.json.jbuilder | 2 +-
.../captain/custom_tools_controller_spec.rb | 7 +-
21 files changed, 307 insertions(+), 57 deletions(-)
create mode 100644 enterprise/app/services/captain/tools/custom_http_tool.rb
diff --git a/app/javascript/dashboard/api/captain/customTools.js b/app/javascript/dashboard/api/captain/customTools.js
index d0818d941..471c2846b 100644
--- a/app/javascript/dashboard/api/captain/customTools.js
+++ b/app/javascript/dashboard/api/captain/customTools.js
@@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient {
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
+
+ test(data = {}) {
+ return axios.post(`${this.url}/test`, {
+ custom_tool: data,
+ });
+ }
}
export default new CaptainCustomTools();
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue
index d1d1dd011..d5f1e3e52 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue
@@ -101,12 +101,9 @@ const authTypeLabel = computed(() => {