From 4c4b70da25b47a1a39b4506fb1587c1861f7d338 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Thu, 26 Mar 2026 18:06:10 +0530 Subject: [PATCH 01/20] fix: Skip email rate limiting for self-hosted instances (#13915) Self-hosted installations were incorrectly hitting the daily email rate limit of 100, seeded from `installation_config`. Since self-hosted users control their own infrastructure, email rate limiting should only apply to Chatwoot Cloud. Closes #13913 --- .../concerns/account_email_rate_limitable.rb | 1 + .../account_email_rate_limitable_spec.rb | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/app/models/concerns/account_email_rate_limitable.rb b/app/models/concerns/account_email_rate_limitable.rb index 806906fe0..5f69e22ee 100644 --- a/app/models/concerns/account_email_rate_limitable.rb +++ b/app/models/concerns/account_email_rate_limitable.rb @@ -17,6 +17,7 @@ module AccountEmailRateLimitable end def within_email_rate_limit? + return true unless ChatwootApp.chatwoot_cloud? return true if emails_sent_today < email_rate_limit Rails.logger.warn("Account #{id} reached daily email rate limit of #{email_rate_limit}. Sent: #{emails_sent_today}") diff --git a/spec/models/concerns/account_email_rate_limitable_spec.rb b/spec/models/concerns/account_email_rate_limitable_spec.rb index 919c5f621..fb9a86144 100644 --- a/spec/models/concerns/account_email_rate_limitable_spec.rb +++ b/spec/models/concerns/account_email_rate_limitable_spec.rb @@ -23,6 +23,7 @@ RSpec.describe AccountEmailRateLimitable do describe '#within_email_rate_limit?' do before do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) account.update!(limits: { 'emails' => 2 }) end @@ -34,6 +35,28 @@ RSpec.describe AccountEmailRateLimitable do 2.times { account.increment_email_sent_count } expect(account).not_to be_within_email_rate_limit end + + context 'when self-hosted' do + before do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) + 2.times { account.increment_email_sent_count } + end + + it 'always returns true regardless of limit' do + expect(account).to be_within_email_rate_limit + end + end + + context 'when chatwoot cloud' do + before do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + 2.times { account.increment_email_sent_count } + end + + it 'returns false when at limit' do + expect(account).not_to be_within_email_rate_limit + end + end end describe '#increment_email_sent_count' do From 4517c5022766b477d85059de44365abf1644462d Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:48:12 +0530 Subject: [PATCH 02/20] feat: support bulk select and delete for documents (#13907) --- .../captain/assistant/DocumentCard.vue | 38 ++++++- .../pageComponents/BulkDeleteDialog.vue | 16 ++- .../i18n/locale/en/integrations.json | 11 ++ .../dashboard/captain/documents/Index.vue | 100 +++++++++++++++++- .../dashboard/captain/responses/Index.vue | 2 +- .../dashboard/captain/responses/Pending.vue | 2 +- .../dashboard/store/captain/bulkActions.js | 21 ++-- .../dashboard/store/captain/document.js | 8 ++ .../captain/bulk_actions_controller.rb | 14 ++- .../captain/bulk_actions_controller_spec.rb | 30 ++++++ 10 files changed, 224 insertions(+), 18 deletions(-) diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue index bb5f03451..30ebfc448 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue @@ -12,6 +12,7 @@ import { import CardLayout from 'dashboard/components-next/CardLayout.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import Button from 'dashboard/components-next/button/Button.vue'; +import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; const props = defineProps({ id: { @@ -34,14 +35,34 @@ const props = defineProps({ type: Number, required: true, }, + isSelected: { + type: Boolean, + default: false, + }, + selectable: { + type: Boolean, + default: false, + }, + showSelectionControl: { + type: Boolean, + default: false, + }, + showMenu: { + type: Boolean, + default: true, + }, }); -const emit = defineEmits(['action']); +const emit = defineEmits(['action', 'select', 'hover']); const { checkPermissions } = usePolicy(); const { t } = useI18n(); const [showActionsDropdown, toggleDropdown] = useToggle(); +const modelValue = computed({ + get: () => props.isSelected, + set: () => emit('select', props.id), +}); const menuItems = computed(() => { const allOptions = [ @@ -79,12 +100,23 @@ const handleAction = ({ action, value }) => { diff --git a/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue index 2144f6f05..e17b26999 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue @@ -316,7 +316,7 @@ onMounted(() => { v-if="bulkSelectedIds" ref="bulkDeleteDialog" :bulk-ids="bulkSelectedIds" - type="Responses" + type="AssistantResponse" @delete-success="onBulkDeleteSuccess" /> diff --git a/app/javascript/dashboard/routes/dashboard/captain/responses/Pending.vue b/app/javascript/dashboard/routes/dashboard/captain/responses/Pending.vue index b26658f9c..661b6ced7 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/responses/Pending.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/responses/Pending.vue @@ -361,7 +361,7 @@ onMounted(() => { v-if="bulkSelectedIds" ref="bulkDeleteDialog" :bulk-ids="bulkSelectedIds" - type="Responses" + type="AssistantResponse" @delete-success="onBulkDeleteSuccess" /> diff --git a/app/javascript/dashboard/store/captain/bulkActions.js b/app/javascript/dashboard/store/captain/bulkActions.js index 42ffcd294..436801092 100644 --- a/app/javascript/dashboard/store/captain/bulkActions.js +++ b/app/javascript/dashboard/store/captain/bulkActions.js @@ -25,17 +25,26 @@ export default createStore({ } }, - handleBulkDelete: async function handleBulkDelete({ dispatch }, ids) { + handleBulkDelete: async function handleBulkDelete( + { dispatch }, + { type = 'AssistantResponse', ids } + ) { const response = await dispatch('processBulkAction', { - type: 'AssistantResponse', + type, actionType: 'delete', ids, }); - // Update the response store after successful API call - await dispatch('captainResponses/removeBulkResponses', ids, { - root: true, - }); + if (type === 'AssistantResponse') { + // Update the response store after successful API call + await dispatch('captainResponses/removeBulkResponses', ids, { + root: true, + }); + } else if (type === 'AssistantDocument') { + await dispatch('captainDocuments/removeBulkRecords', ids, { + root: true, + }); + } return response; }, diff --git a/app/javascript/dashboard/store/captain/document.js b/app/javascript/dashboard/store/captain/document.js index f5766f827..76d0c9124 100644 --- a/app/javascript/dashboard/store/captain/document.js +++ b/app/javascript/dashboard/store/captain/document.js @@ -4,4 +4,12 @@ import { createStore } from '../storeFactory'; export default createStore({ name: 'CaptainDocument', API: CaptainDocumentAPI, + actions: mutations => ({ + removeBulkRecords({ commit, getters }, ids) { + const records = getters.getRecords.filter( + record => !ids.includes(record.id) + ); + commit(mutations.SET, records); + }, + }), }); diff --git a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb index 3130a68cc..e7d02a887 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/bulk_actions_controller.rb @@ -4,7 +4,7 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas before_action :validate_params before_action :type_matches? - MODEL_TYPE = ['AssistantResponse'].freeze + MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze def create @responses = process_bulk_action @@ -28,6 +28,8 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas case params[:type] when 'AssistantResponse' handle_assistant_responses + when 'AssistantDocument' + handle_documents end end @@ -45,6 +47,16 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas end end + def handle_documents + return [] unless params[:fields][:status] == 'delete' + + documents = Current.account.captain_documents.where(id: params[:ids]) + return [] unless documents.exists? + + documents.destroy_all + [] + end + def permitted_params params.permit(:type, ids: [], fields: [:status]) end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb index 968649085..eaecdd89e 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/bulk_actions_controller_spec.rb @@ -14,6 +14,14 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do status: 'pending' ) end + let!(:documents) do + create_list( + :captain_document, + 2, + assistant: assistant, + account: account + ) + end def json_response JSON.parse(response.body, symbolize_names: true) @@ -98,6 +106,28 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do end end + context 'when deleting documents' do + let(:document_delete_params) do + { + type: 'AssistantDocument', + ids: documents.map(&:id), + fields: { status: 'delete' } + } + end + + it 'deletes the documents and returns an empty array' do + expect do + post "/api/v1/accounts/#{account.id}/captain/bulk_actions", + params: document_delete_params, + headers: admin.create_new_auth_token, + as: :json + end.to change(Captain::Document, :count).by(-2) + + expect(response).to have_http_status(:ok) + expect(json_response).to eq([]) + end + end + context 'with missing parameters' do let(:missing_params) do { From 0b41d7f483a626d121771cd5f93df628db5df30b Mon Sep 17 00:00:00 2001 From: Haruma HIRABAYASHI <82667808+hrm1810884@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:53:55 +0900 Subject: [PATCH 03/20] docs(swagger): fix operationId typo `converation` -> `conversation` (#13920) issue: https://github.com/chatwoot/chatwoot/issues/13921 Fix a typo in the Messages API operationId where `converation` was used instead of `conversation`. This causes cspell errors when generating client code with tools like Orval. ## What changed - `swagger/paths/public/inboxes/messages/index.yml`: fixed operationId from `list-all-converation-messages` to `list-all-conversation-messages` - `swagger/swagger.json` and `swagger/tag_groups/client_swagger.json`: regenerated to reflect the fix ## Note If you are using a code generator like Orval against this swagger spec, the generated function name will change from `listAllConverationMessages` to `listAllConversationMessages`. --- swagger/paths/public/inboxes/messages/index.yml | 2 +- swagger/swagger.json | 2 +- swagger/tag_groups/client_swagger.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/swagger/paths/public/inboxes/messages/index.yml b/swagger/paths/public/inboxes/messages/index.yml index 68e34fed7..4477d66c3 100644 --- a/swagger/paths/public/inboxes/messages/index.yml +++ b/swagger/paths/public/inboxes/messages/index.yml @@ -1,6 +1,6 @@ tags: - Messages API -operationId: list-all-converation-messages +operationId: list-all-conversation-messages summary: List all messages description: List all messages in the conversation security: [] diff --git a/swagger/swagger.json b/swagger/swagger.json index 3a9c39728..94d1f04d3 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -1366,7 +1366,7 @@ "tags": [ "Messages API" ], - "operationId": "list-all-converation-messages", + "operationId": "list-all-conversation-messages", "summary": "List all messages", "description": "List all messages in the conversation", "security": [], diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json index f95b1bed3..763e090b1 100644 --- a/swagger/tag_groups/client_swagger.json +++ b/swagger/tag_groups/client_swagger.json @@ -536,7 +536,7 @@ "tags": [ "Messages API" ], - "operationId": "list-all-converation-messages", + "operationId": "list-all-conversation-messages", "summary": "List all messages", "description": "List all messages in the conversation", "security": [], From cac7438fffb9d5a8f9e4bed05f022d5698710cdf Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:14:57 +0530 Subject: [PATCH 04/20] fix: Email Channel links are not working (backend) (#13898) # Pull Request Template ## Description This PR fixes the link formatting issue on the backend by adding `:autolink` to `ChatwootMarkdownRenderer#render_message`, ensuring all URLs are converted to `` tags. Fixes https://linear.app/chatwoot/issue/CW-6682/email-channel-links-are-not-working ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## 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 --------- Co-authored-by: Sojan Jose --- lib/chatwoot_markdown_renderer.rb | 2 +- spec/lib/chatwoot_markdown_renderer_spec.rb | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/chatwoot_markdown_renderer.rb b/lib/chatwoot_markdown_renderer.rb index 50d1755ee..c7adac30a 100644 --- a/lib/chatwoot_markdown_renderer.rb +++ b/lib/chatwoot_markdown_renderer.rb @@ -5,7 +5,7 @@ class ChatwootMarkdownRenderer def render_message markdown_renderer = BaseMarkdownRenderer.new - doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough]) + doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough, :autolink]) html = markdown_renderer.render(doc) render_as_html_safe(html) end diff --git a/spec/lib/chatwoot_markdown_renderer_spec.rb b/spec/lib/chatwoot_markdown_renderer_spec.rb index 5b66ed869..da2a50316 100644 --- a/spec/lib/chatwoot_markdown_renderer_spec.rb +++ b/spec/lib/chatwoot_markdown_renderer_spec.rb @@ -6,11 +6,9 @@ RSpec.describe ChatwootMarkdownRenderer do let(:doc) { instance_double(CommonMarker::Node) } let(:renderer) { described_class.new(markdown_content) } let(:markdown_renderer) { instance_double(CustomMarkdownRenderer) } - let(:base_markdown_renderer) { instance_double(BaseMarkdownRenderer) } let(:html_content) { '

This is a test content with markdown

' } before do - allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT, [:strikethrough]).and_return(doc) allow(CustomMarkdownRenderer).to receive(:new).and_return(markdown_renderer) allow(markdown_renderer).to receive(:render).with(doc).and_return(html_content) end @@ -64,22 +62,28 @@ RSpec.describe ChatwootMarkdownRenderer do end describe '#render_message' do - let(:message_html_content) { '

This is a test content with ^markdown^

' } let(:rendered_message) { renderer.render_message } before do - allow(CommonMarker).to receive(:render_html).with(markdown_content).and_return(message_html_content) - allow(BaseMarkdownRenderer).to receive(:new).and_return(base_markdown_renderer) - allow(base_markdown_renderer).to receive(:render).with(doc).and_return(message_html_content) + allow(CommonMarker).to receive(:render_doc).and_call_original + allow(BaseMarkdownRenderer).to receive(:new).and_call_original end it 'renders the markdown message to html' do - expect(rendered_message.to_s).to eq(message_html_content) + expect(rendered_message.to_s).to eq("

This is a test content with ^markdown^

\n") end it 'returns an html safe string' do expect(rendered_message).to be_html_safe end + + context 'with bare URLs' do + let(:markdown_content) { 'Visit https://example.com for details' } + + it 'converts bare URLs to links' do + expect(renderer.render_message.to_s).to eq("

Visit https://example.com for details

\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 05/20] 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 06/20] 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 07/20] 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 @@ +
+
+
+ i +
+
+

<%= I18n.t('public_portal.not_active.title') %>

+

<%= I18n.t('public_portal.not_active.description') %>

+
+

<%= I18n.t('public_portal.not_active.action') %>

+
+
diff --git a/config/locales/en.yml b/config/locales/en.yml index b0f1f4e2e..12e76ae37 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -423,6 +423,10 @@ en: title: Page not found description: We couldn't find the page you were looking for. back_to_home: Go to home page + not_active: + title: Help Center Unavailable + description: Please contact the site administrator for more information. + action: If you are the administrator, please upgrade your plan to restore access. slack_unfurl: fields: name: Name diff --git a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb index e4df1050b..ac1f74860 100644 --- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb +++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb @@ -7,21 +7,12 @@ class Enterprise::Billing::CreateStripeCustomerService return if existing_subscription? customer_id = prepare_customer_id - subscription = Stripe::Subscription.create( - { - customer: customer_id, - items: [{ price: price_id, quantity: default_quantity }] - } - ) - account.update!( - custom_attributes: { - stripe_customer_id: customer_id, - stripe_price_id: subscription['plan']['id'], - stripe_product_id: subscription['plan']['product'], - plan_name: default_plan['name'], - subscribed_quantity: subscription['quantity'] - } - ) + subscription = Stripe::Subscription.create(customer: customer_id, items: [{ price: price_id, quantity: default_quantity }]) + custom_attributes = build_custom_attributes(customer_id, subscription) + custom_attributes.except!('is_creating_customer') + + account.update!(custom_attributes: custom_attributes) + Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform end private @@ -66,4 +57,23 @@ class Enterprise::Billing::CreateStripeCustomerService ) subscriptions.data.present? end + + def build_custom_attributes(customer_id, subscription) + (account.custom_attributes || {}).merge( + 'stripe_customer_id' => customer_id, + 'stripe_price_id' => subscription['plan']['id'], + 'stripe_product_id' => subscription['plan']['product'], + 'plan_name' => default_plan['name'], + 'subscribed_quantity' => subscription['quantity'], + 'subscription_status' => subscription['status'], + 'subscription_ends_on' => subscription_ends_on(subscription) + ) + end + + def subscription_ends_on(subscription) + period_end = subscription['current_period_end'] + return if period_end.blank? + + Time.zone.at(period_end) + end end diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index d3c5b15db..f69edeb45 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -2,29 +2,9 @@ class Enterprise::Billing::HandleStripeEventService CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze - # Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise - # Each higher tier includes all features from the lower tiers - - # Basic features available starting with the Startups plan - STARTUP_PLAN_FEATURES = %w[ - inbound_emails - help_center - campaigns - team_management - channel_facebook - channel_email - channel_instagram - captain_integration - advanced_search_indexing - advanced_search - linear_integration - ].freeze - - # Additional features available starting with the Business plan - BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze - - # Additional features available only in the Enterprise plan - ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze + STARTUP_PLAN_FEATURES = Enterprise::Billing::ReconcilePlanFeaturesService::STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES = Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES = Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES def perform(event:) @event = event @@ -49,7 +29,7 @@ class Enterprise::Billing::HandleStripeEventService previous_usage = capture_previous_usage update_account_attributes(subscription, plan) - update_plan_features + Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform if billing_period_renewed? ActiveRecord::Base.transaction do @@ -94,34 +74,6 @@ class Enterprise::Billing::HandleStripeEventService Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform end - def update_plan_features - if default_plan? - disable_all_premium_features - else - enable_features_for_current_plan - end - - # Enable any manually managed features configured in internal_attributes - enable_account_manually_managed_features - - account.save! - end - - def disable_all_premium_features - # Disable all features (for default Hacker plan) - account.disable_features(*STARTUP_PLAN_FEATURES) - account.disable_features(*BUSINESS_PLAN_FEATURES) - account.disable_features(*ENTERPRISE_PLAN_FEATURES) - end - - def enable_features_for_current_plan - # First disable all premium features to handle downgrades - disable_all_premium_features - - # Then enable features based on the current plan - enable_plan_specific_features - end - def handle_subscription_credits(plan, previous_usage) current_limits = account.limits || {} @@ -153,19 +105,6 @@ class Enterprise::Billing::HandleStripeEventService config[plan_name.downcase]&.symbolize_keys end - def enable_plan_specific_features - plan_name = account.custom_attributes['plan_name'] - return if plan_name.blank? - - case plan_name - when 'Startups' then account.enable_features(*STARTUP_PLAN_FEATURES) - when 'Business' - account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES) - when 'Enterprise' - account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES, *ENTERPRISE_PLAN_FEATURES) - end - end - def subscription @subscription ||= @event.data.object end @@ -197,19 +136,4 @@ class Enterprise::Billing::HandleStripeEventService cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || [] cloud_plans.find { |config| config['product_id'].include?(plan_id) } end - - def default_plan? - cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || [] - default_plan = cloud_plans.first || {} - account.custom_attributes['plan_name'] == default_plan['name'] - end - - def enable_account_manually_managed_features - # Get manually managed features from internal attributes using the service - service = Internal::Accounts::InternalAttributesService.new(account) - features = service.manually_managed_features - - # Enable each feature - account.enable_features(*features) if features.present? - end end diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb new file mode 100644 index 000000000..953ef0326 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb @@ -0,0 +1,61 @@ +class Enterprise::Billing::ReconcilePlanFeaturesService + CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze + + # Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise + # Each higher tier includes all features from the lower tiers + STARTUP_PLAN_FEATURES = %w[ + inbound_emails + help_center + campaigns + team_management + channel_facebook + channel_email + channel_instagram + captain_integration + advanced_search_indexing + advanced_search + linear_integration + ].freeze + + BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze + ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze + PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze + + pattr_initialize [:account!] + + def perform + account.disable_features(*PREMIUM_PLAN_FEATURES) + account.enable_features(*current_plan_features) + account.enable_features(*manually_managed_features) + account.save! + end + + private + + def current_plan_features + return [] if default_plan? + + case account.custom_attributes['plan_name'] + when 'Startups' then STARTUP_PLAN_FEATURES + when 'Business' then STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + when 'Enterprise' then PREMIUM_PLAN_FEATURES + else [] + end + end + + def default_plan? + default_plan_name = cloud_plans.first&.dig('name') + return false if default_plan_name.blank? + + plan_name = account.custom_attributes['plan_name'] + plan_name.blank? || plan_name == default_plan_name + end + + def cloud_plans + @cloud_plans ||= InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || [] + end + + def manually_managed_features + @manually_managed_features ||= Internal::Accounts::InternalAttributesService.new(account).manually_managed_features + end +end diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb index 7bc69d0a4..116d0a3fc 100644 --- a/enterprise/app/services/internal/accounts/internal_attributes_service.rb +++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb @@ -53,8 +53,8 @@ class Internal::Accounts::InternalAttributesService # Get list of valid features that can be manually managed def valid_feature_list # Business and Enterprise plan features only - Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES + - Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES + Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES + + Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES end # Account notes functionality removed for now diff --git a/spec/controllers/public/api/v1/portals_controller_spec.rb b/spec/controllers/public/api/v1/portals_controller_spec.rb index c5fa94cff..3dd218c8e 100644 --- a/spec/controllers/public/api/v1/portals_controller_spec.rb +++ b/spec/controllers/public/api/v1/portals_controller_spec.rb @@ -13,6 +13,12 @@ RSpec.describe Public::Api::V1::PortalsController, type: :request do end describe 'GET /public/api/v1/portals/{portal_slug}' do + it 'redirects to the portal default locale when locale is not present' do + get "/hc/#{portal.slug}" + + expect(response).to redirect_to("/hc/#{portal.slug}/#{portal.default_locale}") + end + it 'Show portal and categories belonging to the portal' do get "/hc/#{portal.slug}/en" diff --git a/spec/enterprise/controllers/enterprise/public/api/v1/helpcenter_plan_access_spec.rb b/spec/enterprise/controllers/enterprise/public/api/v1/helpcenter_plan_access_spec.rb new file mode 100644 index 000000000..ecba226d7 --- /dev/null +++ b/spec/enterprise/controllers/enterprise/public/api/v1/helpcenter_plan_access_spec.rb @@ -0,0 +1,46 @@ +require 'rails_helper' + +RSpec.describe 'Public Help Center Access', type: :request do + let(:plan_name) { 'Startups' } + let!(:account) { create(:account, custom_attributes: { 'plan_name' => plan_name }) } + let!(:agent) { create(:user, account: account, role: :agent) } + let!(:portal) { create(:portal, account: account, custom_domain: 'docs-helpcenter.example.com') } + let!(:category) { create(:category, portal: portal, account: account, locale: 'en', slug: 'category-slug') } + let!(:article) { create(:article, category: category, portal: portal, account: account, author: agent, status: :published) } + + around do |example| + with_modified_env FRONTEND_URL: 'https://app.chatwoot.com', HELPCENTER_URL: 'https://help.chatwoot.com' do + previous_deployment_env = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV')&.value + InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') + + example.run + ensure + config = InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize + previous_deployment_env.present? ? config.update!(value: previous_deployment_env) : config.destroy! + host! 'www.example.com' + end + end + + it 'blocks chatwoot-hosted portal pages when the help center feature is disabled' do + account.disable_features!(:help_center) + host! 'help.chatwoot.com' + + get "/hc/#{portal.slug}/en" + + expect(response).to have_http_status(:payment_required) + expect(response.body).to include('Help Center Unavailable') + end + + context 'when the account is on the default plan' do + let(:plan_name) { 'Hacker' } + + it 'still allows access if the feature flag is enabled' do + account.enable_features!(:help_center) + host! portal.custom_domain + + get "/hc/#{portal.slug}/articles/#{article.slug}" + + expect(response).to have_http_status(:ok) + end + end +end diff --git a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb index f5b0bbe86..0dd8189c4 100644 --- a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb @@ -7,6 +7,16 @@ describe Enterprise::Billing::CreateStripeCustomerService do let!(:admin1) { create(:user, account: account, role: :administrator) } let(:admin2) { create(:user, account: account, role: :administrator) } let(:subscriptions_list) { double } + let(:current_period_end) { 1_686_567_520 } + let(:subscription_ends_on) { Time.zone.at(current_period_end).as_json } + let(:created_subscription) do + { + plan: { id: 'price_random_number', product: 'prod_random_number' }, + quantity: 2, + status: 'active', + current_period_end: current_period_end + }.with_indifferent_access + end describe '#perform' do before do @@ -18,18 +28,44 @@ describe Enterprise::Billing::CreateStripeCustomerService do ) end + it 'preserves unrelated custom attributes, clears is_creating_customer, and reconciles default-plan features' do + account.update!( + custom_attributes: { + 'is_creating_customer' => true, + 'onboarding_source' => 'billing_page', + 'subscription_status' => 'past_due', + 'subscription_ends_on' => 1.day.ago + } + ) + account.enable_features!(:help_center) + + customer = double + allow(Stripe::Customer).to receive(:create).and_return(customer) + allow(customer).to receive(:id).and_return('cus_random_number') + allow(Stripe::Subscription).to receive(:create).and_return(created_subscription) + + create_stripe_customer_service.new(account: account).perform + + expect(account.reload.custom_attributes).to include( + 'stripe_customer_id' => customer.id, + 'stripe_price_id' => 'price_random_number', + 'stripe_product_id' => 'prod_random_number', + 'subscribed_quantity' => 2, + 'plan_name' => 'A Plan Name', + 'onboarding_source' => 'billing_page', + 'subscription_status' => 'active', + 'subscription_ends_on' => subscription_ends_on + ) + expect(account.custom_attributes).not_to have_key('is_creating_customer') + expect(account).not_to be_feature_enabled('help_center') + end + it 'does not call stripe methods if customer id is present' do account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' }) allow(subscriptions_list).to receive(:data).and_return([]) allow(Stripe::Customer).to receive(:create) allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list) - allow(Stripe::Subscription).to receive(:create) - .and_return( - { - plan: { id: 'price_random_number', product: 'prod_random_number' }, - quantity: 2 - }.with_indifferent_access - ) + allow(Stripe::Subscription).to receive(:create).and_return(created_subscription) create_stripe_customer_service.new(account: account).perform @@ -44,7 +80,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do stripe_price_id: 'price_random_number', stripe_product_id: 'prod_random_number', subscribed_quantity: 2, - plan_name: 'A Plan Name' + plan_name: 'A Plan Name', + subscription_status: 'active', + subscription_ends_on: subscription_ends_on }.with_indifferent_access ) end @@ -53,14 +91,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do customer = double allow(Stripe::Customer).to receive(:create).and_return(customer) allow(customer).to receive(:id).and_return('cus_random_number') - allow(Stripe::Subscription) - .to receive(:create) - .and_return( - { - plan: { id: 'price_random_number', product: 'prod_random_number' }, - quantity: 2 - }.with_indifferent_access - ) + allow(Stripe::Subscription).to receive(:create).and_return(created_subscription) create_stripe_customer_service.new(account: account).perform @@ -75,7 +106,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do stripe_price_id: 'price_random_number', stripe_product_id: 'prod_random_number', subscribed_quantity: 2, - plan_name: 'A Plan Name' + plan_name: 'A Plan Name', + subscription_status: 'active', + subscription_ends_on: subscription_ends_on }.with_indifferent_access ) end @@ -96,12 +129,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do customer = double allow(Stripe::Customer).to receive(:create).and_return(customer) allow(customer).to receive(:id).and_return('cus_random_number') - allow(Stripe::Subscription).to receive(:create).and_return( - { - plan: { id: 'price_random_number', product: 'prod_random_number' }, - quantity: 2 - }.with_indifferent_access - ) + allow(Stripe::Subscription).to receive(:create).and_return(created_subscription) create_stripe_customer_service.new(account: account).perform From 2b296c06fbd23a690c501585aa8f9c4c8e2c5759 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Fri, 27 Mar 2026 00:36:17 -0700 Subject: [PATCH 08/20] chore(security): ignore CVE-2026-33658 for Chatwoot storage defaults (#13922) This ignores `CVE-2026-33658` in `bundler-audit` after validating that Chatwoot's default and recommended storage setups do not use Active Storage proxy mode. Fixes: N/A Closes: N/A ## Why `CVE-2026-33658` is an Active Storage proxy-mode DoS issue triggered by multi-range requests. For Chatwoot, the default and recommended setups do not appear to route file downloads through Rails proxy mode: - `config/environments/production.rb` selects the Active Storage service but does not opt into `rails_storage_proxy` - `.env.example` defaults to `ACTIVE_STORAGE_SERVICE=local` - Chatwoot's storage docs recommend local/cloud storage with optional direct uploads to the storage provider - existing specs expect redirect/disk-style Active Storage URLs rather than proxy-mode URLs Given that validation, ignoring this advisory is a smaller and more accurate response than a framework-wide Rails upgrade. ## What this change does - adds `.bundler-audit.yml` - preserves the existing advisory ignore entries already used by Chatwoot - ignores `CVE-2026-33658` - documents why the ignore is acceptable for Chatwoot's current defaults - notes that this should be revisited if Chatwoot enables `rails_storage_proxy` or other app-served Active Storage proxy routes ## Validation - reviewed `config/environments/production.rb` - reviewed `.env.example` - reviewed Chatwoot storage docs: https://developers.chatwoot.com/self-hosted/deployment/storage/s3-bucket - reviewed Active Storage URL expectations in `spec/controllers/slack_uploads_controller_spec.rb` and `spec/services/line/send_on_line_service_spec.rb` - ran `bundle exec bundle-audit check --no-update` --- .bundler-audit.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.bundler-audit.yml b/.bundler-audit.yml index 7cb453c01..908d97175 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -2,3 +2,8 @@ ignore: - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) - GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+) + # Chatwoot defaults to Active Storage redirect-style URLs, and its recommended + # storage setup uses local/cloud storage with optional direct uploads to the + # storage provider rather than Rails proxy mode. Revisit if we enable + # rails_storage_proxy or other app-served Active Storage proxy routes. + - CVE-2026-33658 From 9efd554693f5bcb3d1c85f4ddd75157399a22834 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:38:17 +0530 Subject: [PATCH 09/20] fix: resolve V2 capacity bypass in team assignment (#13904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When Assignment V2 is enabled, the V2 capacity policies (AgentCapacityPolicy / InboxCapacityLimit) are not respected during team-based assignment paths. The system falls back to the legacy V1 max_assignment_limit, and since V1 is deprecated and typically unconfigured in V2 setups, agents receive unlimited assignments regardless of their V2 capacity. Root cause: Inbox class directly defined member_ids_with_assignment_capacity, which shadowed the Enterprise::InboxAgentAvailability module override in Ruby's method resolution order (MRO). This made the V2 capacity check unreachable (dead code) for any code path using member_ids_with_assignment_capacity. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ⏺ Before the fix 1. Enable assignment_v2 + advanced_assignment on account 2. Create AgentCapacityPolicy with InboxCapacityLimit = 1 for an inbox 3. Assign the policy to an agent (e.g., John) 4. Create 1 open conversation assigned to John (now at capacity) 5. Create a new unassigned conversation in the same inbox 6. Assign a team (containing John) to that conversation 7. Result: John gets assigned despite being at capacity ⏺ After the fix Same steps 1–6. 7. Result: John is NOT assigned — conversation stays unassigned (no agents with capacity available) ## 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 --- enterprise/app/models/enterprise/inbox.rb | 1 + .../enterprise/inbox_agent_availability.rb | 2 +- spec/enterprise/models/inbox_spec.rb | 97 +++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/enterprise/app/models/enterprise/inbox.rb b/enterprise/app/models/enterprise/inbox.rb index 2462122f7..3e156b316 100644 --- a/enterprise/app/models/enterprise/inbox.rb +++ b/enterprise/app/models/enterprise/inbox.rb @@ -1,6 +1,7 @@ module Enterprise::Inbox def member_ids_with_assignment_capacity return super unless enable_auto_assignment? + return filter_by_capacity(available_agents).map(&:user_id) if auto_assignment_v2_enabled? max_assignment_limit = auto_assignment_config['max_assignment_limit'] overloaded_agent_ids = max_assignment_limit.present? ? get_agent_ids_over_assignment_limit(max_assignment_limit) : [] diff --git a/enterprise/app/models/enterprise/inbox_agent_availability.rb b/enterprise/app/models/enterprise/inbox_agent_availability.rb index 886b55278..68bae03e9 100644 --- a/enterprise/app/models/enterprise/inbox_agent_availability.rb +++ b/enterprise/app/models/enterprise/inbox_agent_availability.rb @@ -21,7 +21,7 @@ module Enterprise::InboxAgentAvailability end def capacity_filtering_enabled? - account.feature_enabled?('assignment_v2') && + account.feature_enabled?('advanced_assignment') && account.account_users.joins(:agent_capacity_policy).exists? end diff --git a/spec/enterprise/models/inbox_spec.rb b/spec/enterprise/models/inbox_spec.rb index 4ba1a7021..cfcbdd573 100644 --- a/spec/enterprise/models/inbox_spec.rb +++ b/spec/enterprise/models/inbox_spec.rb @@ -37,6 +37,103 @@ RSpec.describe Inbox do end end + describe 'member_ids_with_assignment_capacity with V2 capacity' do + let(:account) { create(:account) } + let(:v2_inbox) { create(:inbox, account: account, enable_auto_assignment: true) } + let(:agent_capacity_policy) { create(:agent_capacity_policy, account: account) } + + let!(:agent1) { create(:user, account: account, role: :agent, auto_offline: false) } + let!(:agent2) { create(:user, account: account, role: :agent, auto_offline: false) } + + before do + create(:inbox_member, inbox: v2_inbox, user: agent1) + create(:inbox_member, inbox: v2_inbox, user: agent2) + + allow(OnlineStatusTracker).to receive(:get_available_users).and_return( + agent1.id.to_s => 'online', + agent2.id.to_s => 'online' + ) + end + + context 'when assignment_v2 is enabled with capacity policies' do + before do + account.enable_features('assignment_v2', 'advanced_assignment') + account.save! + + create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: v2_inbox, conversation_limit: 1) + agent1.account_users.find_by(account: account).update!(agent_capacity_policy: agent_capacity_policy) + agent2.account_users.find_by(account: account).update!(agent_capacity_policy: agent_capacity_policy) + end + + it 'filters out agents at capacity' do + create(:conversation, inbox: v2_inbox, account: account, assignee: agent1, status: :open) + + result = v2_inbox.member_ids_with_assignment_capacity + expect(result).to include(agent2.id) + expect(result).not_to include(agent1.id) + end + + it 'filters out all agents when all are at capacity' do + create(:conversation, inbox: v2_inbox, account: account, assignee: agent1, status: :open) + create(:conversation, inbox: v2_inbox, account: account, assignee: agent2, status: :open) + + expect(v2_inbox.member_ids_with_assignment_capacity).to be_empty + end + + it 'skips V1 max_assignment_limit when V2 is enabled' do + v2_inbox.update(auto_assignment_config: { max_assignment_limit: 100 }) + + create(:conversation, inbox: v2_inbox, account: account, assignee: agent1, status: :open) + + result = v2_inbox.member_ids_with_assignment_capacity + expect(result).not_to include(agent1.id) + end + end + + context 'when assignment_v2 is enabled without capacity policies' do + before do + account.enable_features('assignment_v2', 'advanced_assignment') + account.save! + end + + it 'returns all online agents' do + result = v2_inbox.member_ids_with_assignment_capacity + expect(result).to contain_exactly(agent1.id, agent2.id) + end + end + + context 'when advanced_assignment is disabled (downgraded account with stale policies)' do + before do + account.enable_features('assignment_v2') + account.save! + + create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: v2_inbox, conversation_limit: 1) + agent1.account_users.find_by(account: account).update!(agent_capacity_policy: agent_capacity_policy) + + create(:conversation, inbox: v2_inbox, account: account, assignee: agent1, status: :open) + end + + it 'does not enforce capacity limits' do + result = v2_inbox.member_ids_with_assignment_capacity + expect(result).to include(agent1.id) + end + end + + context 'when assignment_v2 is disabled (V1 path)' do + before do + v2_inbox.update(auto_assignment_config: { max_assignment_limit: 2 }) + end + + it 'uses V1 max_assignment_limit' do + create_list(:conversation, 2, inbox: v2_inbox, account: account, assignee: agent1, status: :open) + + result = v2_inbox.member_ids_with_assignment_capacity + expect(result).not_to include(agent1.id) + expect(result).to include(agent2.id) + end + end + end + describe 'audit log' do context 'when inbox is created' do it 'has associated audit log created' do From 44a7a13117c0bcd3c9ce6e779258b3f7a18acc1d Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Sat, 28 Mar 2026 21:14:34 -0700 Subject: [PATCH 10/20] fix: Add Estonian to settings language options (#13936) Adds Estonian to the settings language dropdown so accounts can select the existing `et` translation from the UI. Fixes: N/A Closes: N/A ## Why Estonian translation files already exist in the repo and in Crowdin, but the settings dropdown is driven by `LANGUAGES_CONFIG`, where `et` was missing. ## What this change does - Adds `et` / `Eesti (et)` to `LANGUAGES_CONFIG` - Makes Estonian available in the settings language selectors backed by `enabledLanguages` ## Validation - `ruby -c config/initializers/languages.rb` - Opened the local UI at `/app/accounts/1/settings/general` and verified `Eesti (et)` appears in the `Site language` dropdown --------- Co-authored-by: Pranav --- config/initializers/languages.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/initializers/languages.rb b/config/initializers/languages.rb index c34f7a605..183a0e54e 100644 --- a/config/initializers/languages.rb +++ b/config/initializers/languages.rb @@ -42,7 +42,8 @@ LANGUAGES_CONFIG = { 37 => { name: 'עִברִית (he)', iso_639_3_code: 'heb', iso_639_1_code: 'he', enabled: true }, 38 => { name: 'lietuvių (lt)', iso_639_3_code: 'lit', iso_639_1_code: 'lt', enabled: true }, 39 => { name: 'Српски (sr)', iso_639_3_code: 'srp', iso_639_1_code: 'sr', enabled: true }, - 40 => { name: 'български (bg)', iso_639_3_code: 'bul', iso_639_1_code: 'bg', enabled: true } + 40 => { name: 'български (bg)', iso_639_3_code: 'bul', iso_639_1_code: 'bg', enabled: true }, + 41 => { name: 'Eesti keel (et)', iso_639_3_code: 'est', iso_639_1_code: 'et', enabled: true } }.filter { |_key, val| val[:enabled] }.freeze Rails.configuration.i18n.available_locales = LANGUAGES_CONFIG.map { |_index, lang| lang[:iso_639_1_code].to_sym } From 04acc16609ca3eb20d8adea52b607c6fa865a974 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:37:28 +0530 Subject: [PATCH 11/20] fix: skip pay call if invoice already paid after finalize (#13924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When a customer downgrades from Enterprise to Business, they may retain unused Stripe credit balance. During an AI credits topup, Stripe::Invoice.finalize_invoice auto-applies that credit balance to the invoice. If the credit balance fully covers the invoice amount, Stripe marks it as paid immediately upon finalization. Calling Stripe::Invoice.pay on an already-paid invoice throws an error, breaking the topup flow. This fix retrieves the invoice status after finalization and skips the pay call if Stripe has already settled it via credits. ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Tested against Stripe test mode with the following scenarios: - Full credit balance payment: Customer has enough Stripe credit balance to cover the entire invoice. Invoice is marked paid after finalize_invoice — pay is correctly skipped. Credits are fulfilled successfully. - Partial credit balance payment: Customer has some Stripe credit balance but not enough to cover the full amount. Invoice remains open after finalization — pay is called and charges the remaining amount to the default payment method. Credits are fulfilled successfully. - Zero credit balance (normal payment): Customer has no Stripe credit balance. Invoice remains open after finalization — pay charges the full amount. Credits are fulfilled successfully. ## 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 --- .../enterprise/billing/topup_checkout_service.rb | 9 +++++++-- .../enterprise/api/v1/accounts_controller_spec.rb | 1 + .../billing/topup_checkout_service_spec.rb | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb index e11269110..d0ec3e372 100644 --- a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb +++ b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb @@ -73,8 +73,13 @@ class Enterprise::Billing::TopupCheckoutService description: description ) - Stripe::Invoice.finalize_invoice(invoice.id, { auto_advance: false }) - Stripe::Invoice.pay(invoice.id) + finalize_and_pay(invoice.id) + end + + def finalize_and_pay(invoice_id) + Stripe::Invoice.finalize_invoice(invoice_id, { auto_advance: false }) + invoice = Stripe::Invoice.retrieve(invoice_id) + Stripe::Invoice.pay(invoice_id) unless invoice.status == 'paid' end def fulfill_credits(credits, topup_option) diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb index 9089b089a..b2a920b07 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb @@ -281,6 +281,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice) allow(Stripe::InvoiceItem).to receive(:create) allow(Stripe::Invoice).to receive(:finalize_invoice) + allow(Stripe::Invoice).to receive(:retrieve).and_return(Struct.new(:status).new('open')) allow(Stripe::Invoice).to receive(:pay) allow(Stripe::Billing::CreditGrant).to receive(:create) end diff --git a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb index 24c7889de..fa4c052a1 100644 --- a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb @@ -24,6 +24,7 @@ describe Enterprise::Billing::TopupCheckoutService do allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice) allow(Stripe::InvoiceItem).to receive(:create) allow(Stripe::Invoice).to receive(:finalize_invoice) + allow(Stripe::Invoice).to receive(:retrieve).and_return(Struct.new(:status).new('open')) allow(Stripe::Invoice).to receive(:pay) allow(Stripe::Billing::CreditGrant).to receive(:create) end @@ -58,5 +59,19 @@ describe Enterprise::Billing::TopupCheckoutService do expect(error.message).to eq(I18n.t('errors.topup.plan_not_eligible')) end end + + it 'calls pay when invoice is open after finalization' do + service.create_checkout_session(credits: 1000) + + expect(Stripe::Invoice).to have_received(:pay).with('inv_test123') + end + + it 'skips pay when invoice is already paid via Stripe credits' do + allow(Stripe::Invoice).to receive(:retrieve).and_return(Struct.new(:status).new('paid')) + + service.create_checkout_session(credits: 1000) + + expect(Stripe::Invoice).not_to have_received(:pay) + end end end From 7651c18b489bb87ff99969d7f49f76903e8d0017 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 30 Mar 2026 11:32:03 +0530 Subject: [PATCH 12/20] feat: firecrawl branding api [UPM-15] (#13903) Adds `WebsiteBrandingService` (OSS) with an Enterprise override using Firecrawl v2 to extract branding and business data from a URL for onboarding auto-fill. OSS version uses HTTParty + Nokogiri to extract: - Business name (og:site_name or title) - Language (html lang) - Favicon - Social links from `` tags Enterprise version makes a single Firecrawl call to fetch: - Structured JSON (name, language, industry via LLM) - Branding (favicon, primary color) - Page links Falls back to OSS if Firecrawl is unavailable or fails. Social handles (WhatsApp, Facebook, Instagram, Telegram, TikTok, LINE) are parsed deterministically via a shared `SocialLinkParser`. > We use links for socials, since the LLM extraction was unreliable, mostly returned empty, and hallucinated in some rare scenarios ## How to test ```ruby # OSS (no Firecrawl key needed) WebsiteBrandingService.new('chatwoot.com').perform # Enterprise (requires CAPTAIN_FIRECRAWL_API_KEY) WebsiteBrandingService.new('notion.so').perform WebsiteBrandingService.new('postman.com').perform ``` Verify the returned hash includes business_name, language, industry_category, social_handles, and branding with favicon/primary_color. image --- app/services/concerns/social_link_parser.rb | 65 +++++++ app/services/website_branding_service.rb | 93 ++++++++++ .../enterprise/website_branding_service.rb | 112 ++++++++++++ .../website_branding_service_spec.rb | 171 ++++++++++++++++++ .../services/website_branding_service_spec.rb | 156 ++++++++++++++++ 5 files changed, 597 insertions(+) create mode 100644 app/services/concerns/social_link_parser.rb create mode 100644 app/services/website_branding_service.rb create mode 100644 enterprise/app/services/enterprise/website_branding_service.rb create mode 100644 spec/enterprise/services/enterprise/website_branding_service_spec.rb create mode 100644 spec/services/website_branding_service_spec.rb diff --git a/app/services/concerns/social_link_parser.rb b/app/services/concerns/social_link_parser.rb new file mode 100644 index 000000000..fa0cfca06 --- /dev/null +++ b/app/services/concerns/social_link_parser.rb @@ -0,0 +1,65 @@ +module SocialLinkParser + extend ActiveSupport::Concern + + SOCIAL_DOMAIN_MAP = { + whatsapp: %w[wa.me api.whatsapp.com], + line: %w[line.me], + facebook: %w[facebook.com fb.com fb.me], + instagram: %w[instagram.com], + telegram: %w[t.me telegram.me], + tiktok: %w[tiktok.com] + }.freeze + + private + + def extract_social_from_links(links) + handles = {} + SOCIAL_DOMAIN_MAP.each do |platform, domains| + handles[platform] = find_social_handle(links, platform, domains) + end + handles + end + + def find_social_handle(links, platform, domains) + matching_links = links.select do |l| + uri = URI.parse(l) + domains.any? { |d| match_social_domain?(uri.host, d) } + rescue URI::InvalidURIError + false + end + + matching_links.each do |link| + handle = parse_social_handle(platform, link) + return handle if handle.present? + end + nil + end + + def match_social_domain?(host, domain) + return false if host.blank? + + host == domain || host.end_with?(".#{domain}") + end + + SHARE_PATH_PREFIXES = %w[sharer share intent dialog].freeze + + def parse_social_handle(platform, link) + uri = URI.parse(link) + return extract_whatsapp_phone(uri) if platform == :whatsapp + + handle = uri.path.to_s.delete_prefix('/').delete_suffix('/') + return nil if handle.blank? + return nil if SHARE_PATH_PREFIXES.any? { |prefix| handle.start_with?(prefix) } + + handle.presence + rescue URI::InvalidURIError + nil + end + + # wa.me/1234567890 or api.whatsapp.com/send?phone=1234567890 + def extract_whatsapp_phone(uri) + phone = CGI.parse(uri.query.to_s)['phone']&.first + phone = uri.path.to_s.delete_prefix('/').delete_suffix('/') if phone.blank? + phone.presence&.gsub(/[^\d]/, '') + end +end diff --git a/app/services/website_branding_service.rb b/app/services/website_branding_service.rb new file mode 100644 index 000000000..89326e4b6 --- /dev/null +++ b/app/services/website_branding_service.rb @@ -0,0 +1,93 @@ +class WebsiteBrandingService + include SocialLinkParser + + def initialize(url) + @url = normalize_url(url) + end + + def perform + doc = fetch_page + return nil if doc.nil? + + links = extract_links(doc) + + { + business_name: extract_business_name(doc), + language: extract_language(doc), + industry_category: nil, + social_handles: extract_social_from_links(links), + branding: extract_branding(doc) + } + rescue StandardError => e + Rails.logger.error "[WebsiteBranding] #{e.message}" + nil + end + + private + + def normalize_url(url) + url.match?(%r{\Ahttps?://}) ? url : "https://#{url}" + end + + def fetch_page + response = HTTParty.get(@url, follow_redirects: true, timeout: 15) + return nil unless response.success? + + Nokogiri::HTML(response.body) + rescue StandardError => e + Rails.logger.error "[WebsiteBranding] Failed to fetch #{@url}: #{e.message}" + nil + end + + def extract_business_name(doc) + og_site_name = doc.at_css('meta[property="og:site_name"]')&.[]('content') + return og_site_name.strip if og_site_name.present? + + title = doc.at_xpath('//title')&.text + title&.strip&.split(/\s*[|\-–—·:]+\s*/)&.first + end + + def extract_language(doc) + doc.at_css('html')&.[]('lang')&.split('-')&.first&.downcase + end + + def extract_links(doc) + doc.css('a[href]').filter_map do |a| + href = a['href']&.strip + next if href.blank? || href.start_with?('#', 'javascript:', 'mailto:', 'tel:') + + href.start_with?('http') ? href : URI.join(@url, href).to_s + rescue URI::InvalidURIError + nil + end.uniq + end + + def extract_branding(doc) + { + favicon: extract_favicon(doc), + primary_color: extract_theme_color(doc) + } + end + + def extract_favicon(doc) + favicon = doc.at_css('link[rel*="icon"]')&.[]('href') + return nil if favicon.blank? + + resolve_url(favicon) + end + + def extract_theme_color(doc) + doc.at_css('meta[name="theme-color"]')&.[]('content') + end + + def resolve_url(url) + return nil if url.blank? + return url if url.start_with?('http') + + URI.join(@url, url).to_s + rescue URI::InvalidURIError + nil + end +end + +WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService') diff --git a/enterprise/app/services/enterprise/website_branding_service.rb b/enterprise/app/services/enterprise/website_branding_service.rb new file mode 100644 index 000000000..6efdd5051 --- /dev/null +++ b/enterprise/app/services/enterprise/website_branding_service.rb @@ -0,0 +1,112 @@ +module Enterprise::WebsiteBrandingService + FIRECRAWL_SCRAPE_ENDPOINT = 'https://api.firecrawl.dev/v2/scrape'.freeze + + INDUSTRY_CATEGORIES = [ + 'Technology', + 'E-commerce', + 'Healthcare', + 'Education', + 'Finance', + 'Real Estate', + 'Marketing', + 'Travel & Hospitality', + 'Food & Beverage', + 'Media & Entertainment', + 'Professional Services', + 'Non-profit', + 'Other' + ].freeze + + def perform + return super unless firecrawl_enabled? + + response = perform_firecrawl_request + process_firecrawl_response(response) + rescue StandardError => e + Rails.logger.error "[WebsiteBranding] Firecrawl failed: #{e.message}, falling back to basic scrape" + super + end + + private + + def firecrawl_enabled? + firecrawl_api_key.present? + end + + def firecrawl_api_key + InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value + end + + def perform_firecrawl_request + HTTParty.post( + FIRECRAWL_SCRAPE_ENDPOINT, + body: scrape_payload.to_json, + headers: { + 'Authorization' => "Bearer #{firecrawl_api_key}", + 'Content-Type' => 'application/json' + } + ) + end + + def scrape_payload + { + url: @url, + onlyMainContent: false, + formats: [ + { + type: 'json', + schema: extract_schema, + prompt: 'Extract the business name, primary language, and industry category from this website.' + }, + 'branding', + 'links' + ] + } + end + + def extract_schema + { + type: 'object', + properties: { + business_name: { type: 'string', description: 'The name of the business or company' }, + language: { type: 'string', description: 'Primary language as ISO 639-1 code (e.g., en, es, fr)' }, + industry_category: { type: 'string', enum: INDUSTRY_CATEGORIES, description: 'Industry category for this business' } + }, + required: %w[business_name] + } + end + + def process_firecrawl_response(response) + raise "API Error: #{response.message} (Status: #{response.code})" unless response.success? + + format_firecrawl_response(response) + end + + def format_firecrawl_response(response) + data = response.parsed_response + extract = data.dig('data', 'json') || {} + brand = data.dig('data', 'branding') || {} + links = data.dig('data', 'links') || [] + + { + business_name: extract['business_name'], + language: extract['language'], + industry_category: extract['industry_category'], + social_handles: extract_social_from_links(links), + branding: extract_firecrawl_branding(brand) + } + end + + def extract_firecrawl_branding(brand) + { + favicon: url_or_nil(brand.dig('images', 'favicon')), + primary_color: brand.dig('colors', 'primary') + } + end + + def url_or_nil(value) + return nil if value.blank? || !value.start_with?('http') + + value + end +end diff --git a/spec/enterprise/services/enterprise/website_branding_service_spec.rb b/spec/enterprise/services/enterprise/website_branding_service_spec.rb new file mode 100644 index 000000000..0907db518 --- /dev/null +++ b/spec/enterprise/services/enterprise/website_branding_service_spec.rb @@ -0,0 +1,171 @@ +require 'rails_helper' + +# Simulate the prepend_mod_with behavior for testing +test_klass = Class.new(WebsiteBrandingService) do + prepend Enterprise::WebsiteBrandingService +end + +RSpec.describe Enterprise::WebsiteBrandingService do + describe '#perform' do + subject(:service) { test_klass.new(url) } + + let(:url) { 'https://example.com' } + let(:api_key) { 'test-firecrawl-api-key' } + let(:scrape_endpoint) { described_class::FIRECRAWL_SCRAPE_ENDPOINT } + let(:fallback_html) { 'Fallback' } + let(:success_response_body) do + { + success: true, + data: { + json: { + business_name: 'Acme Corp', + language: 'en', + industry_category: 'Technology' + }, + branding: { + images: { logo: 'https://example.com/logo.png', favicon: 'https://example.com/favicon.png' }, + colors: { primary: '#FF5733' } + }, + links: [ + 'https://example.com/about', + 'https://facebook.com/acmecorp', + 'https://instagram.com/acme_corp', + 'https://wa.me/1234567890', + 'https://t.me/acmecorp', + 'https://tiktok.com/@acmetok' + ] + } + }.to_json + end + + before do + stub_request(:get, url).to_return(status: 200, body: fallback_html, headers: { 'content-type' => 'text/html' }) + end + + context 'when firecrawl is configured and API returns success' do + before do + create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key) + stub_request(:post, scrape_endpoint) + .with(headers: { 'Authorization' => "Bearer #{api_key}", 'Content-Type' => 'application/json' }) + .to_return(status: 200, body: success_response_body, headers: { 'content-type' => 'application/json' }) + end + + it 'returns business info and branding from firecrawl' do + result = service.perform + + expect(result).to eq({ + business_name: 'Acme Corp', + language: 'en', + industry_category: 'Technology', + social_handles: { + whatsapp: '1234567890', + line: nil, + facebook: 'acmecorp', + instagram: 'acme_corp', + telegram: 'acmecorp', + tiktok: '@acmetok' + }, + branding: { + favicon: 'https://example.com/favicon.png', + primary_color: '#FF5733' + } + }) + end + end + + context 'when firecrawl API returns an error' do + before do + create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key) + stub_request(:post, scrape_endpoint) + .to_return(status: 422, body: '{"error": "Invalid URL"}', headers: {}) + end + + it 'falls back to basic scrape' do + result = service.perform + expect(result[:business_name]).to eq('Fallback') + expect(result[:industry_category]).to be_nil + end + end + + context 'when firecrawl raises an exception' do + before do + create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key) + stub_request(:post, scrape_endpoint).to_raise(StandardError.new('connection refused')) + end + + it 'falls back to basic scrape' do + result = service.perform + expect(result[:business_name]).to eq('Fallback') + end + end + + context 'when firecrawl is not configured' do + it 'uses basic scrape' do + expect(HTTParty).not_to receive(:post) + result = service.perform + expect(result[:business_name]).to eq('Fallback') + end + end + + context 'when WhatsApp link uses api.whatsapp.com format' do + before do + create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key) + response = { + success: true, + data: { + json: { business_name: 'Acme Corp' }, + links: ['https://api.whatsapp.com/send?phone=5511999999999&text=Hello'] + } + }.to_json + stub_request(:post, scrape_endpoint) + .to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' }) + end + + it 'extracts phone number from query param' do + result = service.perform + expect(result[:social_handles][:whatsapp]).to eq('5511999999999') + end + end + + context 'when WhatsApp link uses wa.me format' do + before do + create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key) + response = { + success: true, + data: { + json: { business_name: 'Acme Corp' }, + links: ['https://wa.me/+5511999999999'] + } + }.to_json + stub_request(:post, scrape_endpoint) + .to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' }) + end + + it 'extracts phone number from path' do + result = service.perform + expect(result[:social_handles][:whatsapp]).to eq('5511999999999') + end + end + + context 'when links contain lookalike domains' do + before do + create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key) + response = { + success: true, + data: { + json: { business_name: 'Acme Corp' }, + links: ['https://notfacebook.com/page', 'https://fakeinstagram.com/user'] + } + }.to_json + stub_request(:post, scrape_endpoint) + .to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' }) + end + + it 'does not match lookalike domains' do + result = service.perform + expect(result[:social_handles][:facebook]).to be_nil + expect(result[:social_handles][:instagram]).to be_nil + end + end + end +end diff --git a/spec/services/website_branding_service_spec.rb b/spec/services/website_branding_service_spec.rb new file mode 100644 index 000000000..19598fb59 --- /dev/null +++ b/spec/services/website_branding_service_spec.rb @@ -0,0 +1,156 @@ +require 'rails_helper' + +RSpec.describe WebsiteBrandingService do + describe '#perform' do + let(:url) { 'https://example.com' } + let(:html_body) do + <<~HTML + + + Acme Corp | Home + + + + + + +
Home
+ + + + HTML + end + + before do + stub_request(:get, url).to_return(status: 200, body: html_body, headers: { 'content-type' => 'text/html' }) + end + + it 'extracts business info, branding, and social handles' do + result = described_class.new(url).perform + + expect(result).to eq({ + business_name: 'Acme Corp', + language: 'en', + industry_category: nil, + social_handles: { + whatsapp: '1234567890', + line: nil, + facebook: 'acmecorp', + instagram: 'acme_corp', + telegram: 'acmecorp', + tiktok: '@acmetok' + }, + branding: { + favicon: 'https://example.com/favicon.ico', + primary_color: '#FF5733' + } + }) + end + + context 'when og:site_name is missing' do + let(:html_body) do + <<~HTML + + Mon Entreprise - Bienvenue + + + HTML + end + + it 'falls back to the first segment of the title' do + result = described_class.new(url).perform + expect(result[:business_name]).to eq('Mon Entreprise') + expect(result[:language]).to eq('fr') + end + end + + context 'when the page fails to load' do + before { stub_request(:get, url).to_return(status: 500, body: '') } + + it 'returns nil' do + expect(described_class.new(url).perform).to be_nil + end + end + + context 'when a network error occurs' do + before { stub_request(:get, url).to_raise(StandardError.new('connection refused')) } + + it 'logs the error and returns nil' do + expect(Rails.logger).to receive(:error).with(/connection refused/) + expect(described_class.new(url).perform).to be_nil + end + end + + context 'when URL has no scheme' do + before do + stub_request(:get, 'https://example.com').to_return(status: 200, body: html_body, headers: { 'content-type' => 'text/html' }) + end + + it 'prepends https://' do + result = described_class.new('example.com').perform + expect(result[:business_name]).to eq('Acme Corp') + end + end + + context 'when WhatsApp link uses api.whatsapp.com format' do + let(:html_body) do + <<~HTML + + Test + Chat + + HTML + end + + it 'extracts phone from query param' do + result = described_class.new(url).perform + expect(result[:social_handles][:whatsapp]).to eq('5511999999999') + end + end + + context 'when links contain lookalike domains' do + let(:html_body) do + <<~HTML + + Test + + Not FB + Not IG + + + HTML + end + + it 'does not match lookalike domains' do + result = described_class.new(url).perform + expect(result[:social_handles][:facebook]).to be_nil + expect(result[:social_handles][:instagram]).to be_nil + end + end + + context 'when favicon uses a relative path without leading slash' do + let(:html_body) do + <<~HTML + + + Test + + + + + HTML + end + + it 'resolves the relative favicon URL' do + result = described_class.new(url).perform + expect(result[:branding][:favicon]).to eq('https://example.com/favicon.ico') + end + end + end +end From b9f824b43bc769a55570f63931cd3e8e8086ab03 Mon Sep 17 00:00:00 2001 From: Alok Dangre <148090007+alokdangre@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:05:28 +0530 Subject: [PATCH 13/20] fix(ui): resolve unreadable select options in dark mode (#13207) --- app/javascript/dashboard/assets/scss/_base.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/javascript/dashboard/assets/scss/_base.scss b/app/javascript/dashboard/assets/scss/_base.scss index 84c8a4b0f..108da3889 100644 --- a/app/javascript/dashboard/assets/scss/_base.scss +++ b/app/javascript/dashboard/assets/scss/_base.scss @@ -106,6 +106,10 @@ select { &[disabled] { @apply field-disabled; } + + option:not(:disabled) { + @apply bg-n-solid-2 text-n-slate-12; + } } // Textarea From 42441dbd2828e3109eee5c7c07f9a8427a86ae4a Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:19:02 +0530 Subject: [PATCH 14/20] feat: add GuideJar embed support in HC (#13944) --- config/markdown_embeds.yml | 12 +++++++++++ spec/config/markdown_embeds_spec.rb | 8 ++++++- spec/lib/custom_markdown_renderer_spec.rb | 26 +++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/config/markdown_embeds.yml b/config/markdown_embeds.yml index b826931ec..cc5b997d6 100644 --- a/config/markdown_embeds.yml +++ b/config/markdown_embeds.yml @@ -134,6 +134,18 @@ codepen: +guidejar: + regex: 'https?://(?:www\.)?guidejar\.com/(?:embed|guides)/(?[^&/?]+)' + template: | +
+ +
+ 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 15/20] 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 16/20] 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 17/20] 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 18/20] 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 19/20] 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 20/20] 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; + } }, }, };