From 59d869d1edcbc9b748e3521f7d12ac7f592b6d04 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Tue, 9 Jun 2026 18:04:48 +0530
Subject: [PATCH 01/22] feat: Ability to resize table column width (#14611)
---
.../shared/helpers/MessageFormatter.js | 9 +-
.../helpers/specs/MessageFormatter.spec.js | 10 +++
lib/custom_markdown_renderer.rb | 82 ++++++++++++++++++-
package.json | 2 +-
pnpm-lock.yaml | 10 +--
spec/lib/custom_markdown_renderer_spec.rb | 53 ++++++++++++
6 files changed, 157 insertions(+), 9 deletions(-)
diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js
index eb8fecf01..85ce67bc6 100644
--- a/app/javascript/shared/helpers/MessageFormatter.js
+++ b/app/javascript/shared/helpers/MessageFormatter.js
@@ -63,6 +63,13 @@ const createMarkdownInstance = (linkify = true) => {
});
};
+// Help center article tables persist column widths as an internal
+// `` comment before the table. It exists only for the
+// editor's markdown round-trip and must never surface as text — markdown-it runs
+// with `html: false`, which would otherwise escape it into a visible comment in
+// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
+const COLWIDTHS_MARKER_REGEX = /\r?\n?/g;
+
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
@@ -75,7 +82,7 @@ class MessageFormatter {
isAPrivateNote = false,
linkify = true
) {
- this.message = message || '';
+ this.message = (message || '').replace(COLWIDTHS_MARKER_REGEX, '');
this.isAPrivateNote = isAPrivateNote;
this.isATweet = isATweet;
this.linkify = linkify;
diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
index 12b84085c..3350399eb 100644
--- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
+++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
@@ -126,6 +126,16 @@ describe('#MessageFormatter', () => {
});
});
+ describe('help center table colwidth marker', () => {
+ it('strips the internal colwidths marker from rendered output', () => {
+ const message =
+ '\n| A | B |\n| --- | --- |\n| 1 | 2 |';
+ const formatter = new MessageFormatter(message);
+ expect(formatter.formattedMessage).not.toContain('cw-colwidths');
+ expect(formatter.plainText).not.toContain('cw-colwidths');
+ });
+ });
+
describe('#sanitize', () => {
it('sanitizes markup and removes all unnecessary elements', () => {
const message =
diff --git a/lib/custom_markdown_renderer.rb b/lib/custom_markdown_renderer.rb
index 665d4c80c..fa191f5ed 100644
--- a/lib/custom_markdown_renderer.rb
+++ b/lib/custom_markdown_renderer.rb
@@ -9,9 +9,32 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
@embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) }
end
+ # Matches columnResizing({ cellMinWidth: 50 }) in @chatwoot/prosemirror-schema
+ # so cells without an explicit colwidth render the same minimum here as in the editor.
+ TABLE_CELL_MIN_WIDTH_PX = 50
+ COLWIDTHS_COMMENT = //
+
+ # The article editor serializes column widths as a `` HTML
+ # comment immediately before each resized table. Capture it (emitting nothing) so the
+ # next `table` can size itself; any other raw HTML keeps its default rendering.
+ def html(node)
+ match = node.string_content.match(COLWIDTHS_COMMENT)
+ return super unless match
+
+ @pending_colwidths = match[1].split(',').map(&:to_i)
+ end
+
def table(node)
- out('
')
- super
+ widths = @pending_colwidths
+ @pending_colwidths = nil
+
+ if sized_widths?(widths)
+ out(table_wrapper_open(widths))
+ out(inject_table_sizing(capture_html { super(node) }, widths))
+ else
+ out('
')
+ super
+ end
out('
')
end
@@ -47,6 +70,61 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
private
+ def sized_widths?(widths)
+ widths.is_a?(Array) && widths.any? { |w| w.to_i.positive? }
+ end
+
+ def fully_sized?(widths)
+ widths.all? { |w| w.to_i.positive? }
+ end
+
+ # Fully-sized tables hug their exact width so the card doesn't trail empty space;
+ # partial tables stay a plain full-width card so flexible columns can expand.
+ def table_wrapper_open(widths)
+ return '
' unless fully_sized?(widths)
+
+ %(
)
+ end
+
+ # Let the gem render the whole table, then splice a
and sizing style
+ # into the opening tag. Delegating the row/cell/tbody/alignment markup to
+ # super keeps this working across commonmarker upgrades.
+ # `!important` overrides the portal's `[&_table]:!min-w-full` Tailwind rule.
+ def inject_table_sizing(html, widths)
+ opening = %(\n#{colgroup_html(widths)})
+ html.sub(/]*>\n?/, opening)
+ end
+
+ # Capture everything `super` writes by swapping the renderer's output buffer.
+ def capture_html
+ original = @stream
+ @stream = StringIO.new(+'')
+ yield
+ @stream.string
+ ensure
+ @stream = original
+ end
+
+ # Total table width: each column's saved width, or the cell min for unsized ones.
+ def total_width(widths)
+ widths.sum { |w| w.to_i.positive? ? w.to_i : TABLE_CELL_MIN_WIDTH_PX }
+ end
+
+ # Fully sized → lock to the exact total (min-width too, so a narrow saved width
+ # beats the portal's `[&_table]:!min-w-full`). Partial → `max(100%, total)` fills
+ # the container (flexible columns) yet scrolls when the sized columns exceed it.
+ def table_sizing_style(widths)
+ total = total_width(widths)
+ return "table-layout: fixed; min-width: max(100%, #{total}px) !important;" unless fully_sized?(widths)
+
+ "table-layout: fixed; width: #{total}px !important; min-width: #{total}px !important;"
+ end
+
+ def colgroup_html(widths)
+ cols = widths.map { |w| w.to_i.positive? ? %() : '' }
+ "#{cols.join}\n"
+ end
+
def extract_image_width(src)
query = URI.parse(src).query
raw = query && CGI.parse(query)['cw_image_width']&.first
diff --git a/package.json b/package.json
index d8527051d..41313c8b3 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.17",
+ "@chatwoot/prosemirror-schema": "1.3.19",
"@chatwoot/utils": "^0.0.55",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a4b61061c..68e667953 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,8 +25,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.3.17
- version: 1.3.17
+ specifier: 1.3.19
+ version: 1.3.19
'@chatwoot/utils':
specifier: ^0.0.55
version: 0.0.55
@@ -458,8 +458,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.3.17':
- resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==}
+ '@chatwoot/prosemirror-schema@1.3.19':
+ resolution: {integrity: sha512-LbATIAeTzclvbIK6WjtrGUO37AtMWkzCJi+s/KpUIp81TORhc0fHnclGT3353AbxeH6dF51/4hFTTsmF+ziqGA==}
'@chatwoot/utils@0.0.55':
resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
@@ -5128,7 +5128,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.3.17':
+ '@chatwoot/prosemirror-schema@1.3.19':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.7.1
diff --git a/spec/lib/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb
index 28c5e069c..6484f2e6c 100644
--- a/spec/lib/custom_markdown_renderer_spec.rb
+++ b/spec/lib/custom_markdown_renderer_spec.rb
@@ -258,6 +258,59 @@ describe CustomMarkdownRenderer do
end
end
+ describe '#table' do
+ def render_table(markdown)
+ doc = CommonMarker.render_doc(markdown, :DEFAULT, [:table])
+ described_class.new.render(doc)
+ end
+
+ let(:plain_table) { "| A | B |\n| --- | --- |\n| 1 | 2 |\n" }
+
+ it 'renders a table without column widths when no marker is present' do
+ output = render_table(plain_table)
+ expect(output).to include('')
+ expect(output).not_to include('colgroup')
+ expect(output).not_to include('cw-colwidths')
+ end
+
+ context 'when every column has a saved width' do
+ it 'lays the table out at the total width with a sized colgroup' do
+ output = render_table("\n#{plain_table}")
+ # Wrapper hugs the table; min-width is set alongside width so a narrow saved width beats min-w-full.
+ expect(output).to include('')
+ expect(output).to include('
')
+ expect(output).to include('')
+ end
+ end
+
+ context 'when only some columns have a saved width' do
+ it 'fills the container so unsized columns stay flexible, floored at the sized total' do
+ output = render_table("\n#{plain_table}")
+ # max(100%, 200px): fills the container (flexible) but scrolls if the sized columns exceed it.
+ expect(output).to include('table-layout: fixed; min-width: max(100%, 200px) !important;')
+ expect(output).to include('')
+ # No exact-width lock on the wrapper or table — the table must be free to expand.
+ expect(output).to include('')
+ expect(output).to include('width: 400px !important;')
+ expect(output).to include('')
+ expect(output.scan('colgroup').length).to eq(2)
+ end
+
+ it 'does not emit the marker comment into the rendered html' do
+ expect(render_table("\n#{plain_table}")).not_to include('cw-colwidths')
+ end
+ end
+
describe '#image' do
it 'renders width in px with responsive cap and auto height' do
markdown = ''
From 33f75505259362d1a2e23cdcbb70918ced58470c Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Wed, 10 Jun 2026 08:52:34 +0400
Subject: [PATCH 02/22] fix(whatsapp): restrict OGG voice recording to WhatsApp
Cloud inboxes (#14692)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Recording and sending an audio message from a **Twilio WhatsApp** inbox
failed silently — Twilio rejected the media with delivery error `63019`
("Media failed to download") and the voice note never reached the
customer. This restores audio sending for Twilio WhatsApp (and
360dialog) inboxes.
Closes Regression from #14606
## How to reproduce
1. Open a conversation in a **Twilio WhatsApp** inbox.
2. Record a voice message in the reply box and send it.
3. Before this fix: the message fails to deliver and a
`Webhooks::TwilioDeliveryStatusJob` is enqueued with `ErrorCode: 63019`,
`ErrorMessage: "Media failed to download"`.
4. After this fix: the audio is recorded as MP3 and delivers normally.
## What changed
PR #14606 added WhatsApp **Cloud** voice notes, which require OGG/Opus.
It changed `audioRecordFormat` in `ReplyBox.vue` to return OGG for
`isAWhatsAppChannel` — but that getter is also `true` for Twilio
WhatsApp inboxes. The OGG handling (content-type normalization + the
`voice: true` flag) lives only in `WhatsappCloudService`, so Twilio
could not download/process the remuxed OGG file.
This change scopes OGG to `isAWhatsAppCloudChannel`. Twilio WhatsApp,
360dialog, and Telegram fall back to MP3 exactly as they did before the
PR.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context)
---
.../dashboard/components/widgets/conversation/ReplyBox.vue | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index 17f5559f1..fc93ff2c7 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -375,10 +375,10 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
- if (this.isAWhatsAppChannel) {
+ if (this.isAWhatsAppCloudChannel) {
return AUDIO_FORMATS.OGG;
}
- if (this.isATelegramChannel) {
+ if (this.isAWhatsAppChannel || this.isATelegramChannel) {
return AUDIO_FORMATS.MP3;
}
if (this.isAPIInbox) {
From cabe9bc7332e89656cdd1ec8e30f4134d6b993a9 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Wed, 10 Jun 2026 10:56:17 +0530
Subject: [PATCH 03/22] fix: populate general settings form on hard reload
(#14685)
---
.../routes/dashboard/settings/account/Index.vue | 12 +++++++++++-
.../settings/account/components/AccountId.vue | 2 +-
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
index 0502ebc1b..55c1e7f03 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
@@ -94,8 +94,18 @@ export default {
return this.getAccount(this.accountId) || {};
},
},
+ watch: {
+ 'currentAccount.id'(id) {
+ if (id) {
+ this.initializeAccount();
+ }
+ },
+ },
mounted() {
- this.initializeAccount();
+ // Account already in the store (navigated in): seed immediately.
+ if (this.currentAccount.id) {
+ this.initializeAccount();
+ }
},
methods: {
async initializeAccount() {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/components/AccountId.vue b/app/javascript/dashboard/routes/dashboard/settings/account/components/AccountId.vue
index f02efcdc2..941d79fb1 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/account/components/AccountId.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/account/components/AccountId.vue
@@ -8,7 +8,7 @@ import SectionLayout from './SectionLayout.vue';
const { t } = useI18n();
const { currentAccount } = useAccount();
-const getAccountId = computed(() => currentAccount.value.id.toString());
+const getAccountId = computed(() => currentAccount.value?.id?.toString());
From 72a59e4795804775d6babacbd584e498572035f2 Mon Sep 17 00:00:00 2001
From: Vishnu Narayanan
Date: Wed, 10 Jun 2026 12:33:46 +0530
Subject: [PATCH 04/22] fix: update oauth dependencies (#14691)
Updates the locked OAuth dependencies to patched versions so bundle
audit no longer reports the current OAuth advisories.
What changed
- Updated `oauth` from `1.1.0` to `1.1.6` for `GHSA-prq8-7wvh-44qh`.
- Updated `oauth2` from `2.0.9` to `2.0.22` for `GHSA-pp92-crg2-gfv9`.
- Accepted Bundler's required transitive lockfile updates for the
patched OAuth gems.
How to test
1. Run `bundle exec bundle audit update && bundle exec bundle audit
check -v` and confirm no vulnerabilities are reported.
2. Smoke test OAuth-based authentication and email integration flows.
Co-authored-by: Shivam Mishra
---
Gemfile.lock | 51 +++++++++++--------
.../base_refresh_oauth_token_service.rb | 2 +
2 files changed, 33 insertions(+), 20 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index ad2a96921..141afc122 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -136,6 +136,8 @@ GEM
audited (5.4.1)
activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7)
+ auth-sanitizer (0.2.1)
+ version_gem (~> 1.1, >= 1.1.10)
aws-actionmailbox-ses (0.1.0)
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
@@ -168,7 +170,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
- bigdecimal (3.2.2)
+ bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -184,6 +186,7 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
+ cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
@@ -312,7 +315,7 @@ GEM
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
- faraday-net_http (3.4.2)
+ faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
@@ -435,7 +438,8 @@ GEM
hana (1.3.7)
hash_diff (1.1.1)
hashdiff (1.1.0)
- hashie (5.0.0)
+ hashie (5.1.0)
+ logger
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
http (5.1.1)
@@ -470,7 +474,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
- json (2.19.5)
+ json (2.19.8)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -568,7 +572,7 @@ GEM
ruby2_keywords
msgpack (1.8.0)
multi_json (1.15.0)
- multi_xml (0.8.0)
+ multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
mutex_m (0.3.0)
@@ -603,19 +607,26 @@ GEM
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
- oauth (1.1.0)
- oauth-tty (~> 1.0, >= 1.0.1)
- snaky_hash (~> 2.0)
- version_gem (~> 1.1)
- oauth-tty (1.0.5)
- version_gem (~> 1.1, >= 1.1.1)
- oauth2 (2.0.9)
- faraday (>= 0.17.3, < 3.0)
- jwt (>= 1.0, < 3.0)
+ oauth (1.1.6)
+ auth-sanitizer (~> 0.2, >= 0.2.1)
+ base64 (~> 0.1)
+ cgi
+ oauth-tty (~> 1.0, >= 1.0.8)
+ snaky_hash (~> 2.0, >= 2.0.5)
+ version_gem (~> 1.1, >= 1.1.11)
+ oauth-tty (1.0.8)
+ auth-sanitizer (~> 0.1, >= 0.1.3)
+ cgi
+ version_gem (~> 1.1, >= 1.1.9)
+ oauth2 (2.0.22)
+ auth-sanitizer (~> 0.2, >= 0.2.1)
+ faraday (>= 0.17.3, < 4.0)
+ jwt (>= 1.0, < 4.0)
+ logger (~> 1.2)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
- snaky_hash (~> 2.0)
- version_gem (~> 1.1)
+ snaky_hash (~> 2.0, >= 2.0.5)
+ version_gem (~> 1.1, >= 1.1.11)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
@@ -935,9 +946,9 @@ GEM
gli
hashie
logger
- snaky_hash (2.0.1)
- hashie
- version_gem (~> 1.1, >= 1.1.1)
+ snaky_hash (2.0.5)
+ hashie (>= 0.1.0, < 6)
+ version_gem (>= 1.1.8, < 3)
sorbet-runtime (0.5.11934)
spring (4.1.1)
spring-watcher-listen (2.1.0)
@@ -995,7 +1006,7 @@ GEM
valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
- version_gem (1.1.4)
+ version_gem (1.1.11)
vite_rails (3.10.0)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
diff --git a/app/services/base_refresh_oauth_token_service.rb b/app/services/base_refresh_oauth_token_service.rb
index caf4a839c..1e89c212c 100644
--- a/app/services/base_refresh_oauth_token_service.rb
+++ b/app/services/base_refresh_oauth_token_service.rb
@@ -23,6 +23,8 @@ class BaseRefreshOauthTokenService
# Refresh the access tokens using the refresh token
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
def refresh_tokens
+ raise 'A refresh_token is not available' if provider_config[:refresh_token].blank?
+
oauth_strategy = build_oauth_strategy
token_service = build_token_service(oauth_strategy)
From 3dfb5061e13395e290625a9d9b2f60d78818e92b Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Wed, 10 Jun 2026 14:01:30 +0530
Subject: [PATCH 05/22] refactor: route captain custom tool requests through
SafeFetch (#14620)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Captain custom tools previously used their own hand-rolled `Net::HTTP`
request code. This moves them onto `SafeFetch`, the same shared
HTTP-fetching helper already used by webhooks, uploads, avatar imports,
and the Captain page crawler. Behavior for normal tools is unchanged —
they just now share one consistent path for host resolution, timeouts,
response size limits, and redirect handling.
**What changed**
- `HttpTool#execute_http_request` now delegates to `SafeFetch.fetch`
instead of building `Net::HTTP` requests by hand. Auth headers, basic
auth, metadata headers, JSON content-type, and the 1 MB response cap all
map onto `SafeFetch` options.
- Removed ~80 lines of bespoke request/validation plumbing from
`HttpTool`.
- The custom tools `test` endpoint now reads the response body string
directly (the executor returns the body rather than a response object).
**How to test**
1. Enable `custom_tools` (or `captain_integration_v2`) for an account.
2. Create a Captain custom tool pointing at a public HTTPS endpoint
(e.g. a test API).
3. Use the **Test** button in the tool form — you should get a success
result.
4. Run the tool from a Captain conversation and confirm the response is
returned/templated as before.
**Gotchas**
- **Local dev:** `SafeFetch` blocks requests to private/loopback
addresses by default. If you're testing a custom tool against a service
on `localhost` or a private IP during development, set
`SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true` or the request will be rejected.
(This matches how the rest of `SafeFetch` already behaves locally.)
- **Test endpoint status field:** on success the `test` response now
reports `status: 200` rather than the exact 2xx code (201/204/etc.),
because `SafeFetch` signals success-vs-failure rather than exposing the
raw response. The UI only checks the 2xx range, so this is invisible
there — but worth knowing if anything consumes the API directly.
- **Non-2xx responses** still surface as an error (same as before), now
via `SafeFetch::HttpError`.
---
.../captain/custom_tools_controller.rb | 4 +-
enterprise/lib/captain/tools/http_tool.rb | 97 ++++---------------
2 files changed, 22 insertions(+), 79 deletions(-)
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
index fab952960..874197870 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
@@ -27,8 +27,8 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
def test
tool = account_custom_tools.new(custom_tool_params)
- result = execute_test_request(tool)
- render json: { status: result.code.to_i, body: result.body.to_s.truncate(500) }
+ body = execute_test_request(tool)
+ render json: { status: 200, body: body.to_s.truncate(500) }
rescue StandardError => e
render json: { error: e.message }, status: :unprocessable_content
end
diff --git a/enterprise/lib/captain/tools/http_tool.rb b/enterprise/lib/captain/tools/http_tool.rb
index b4593d27f..18ebcf53a 100644
--- a/enterprise/lib/captain/tools/http_tool.rb
+++ b/enterprise/lib/captain/tools/http_tool.rb
@@ -15,8 +15,8 @@ class Captain::Tools::HttpTool < Agents::Tool
url = @custom_tool.build_request_url(params)
body = @custom_tool.build_request_body(params)
- response = execute_http_request(url, body, tool_context)
- @custom_tool.format_response(response.body)
+ response_body = execute_http_request(url, body, tool_context)
+ @custom_tool.format_response(response_body)
rescue StandardError => e
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
'An error occurred while executing the request'
@@ -24,89 +24,32 @@ class Captain::Tools::HttpTool < Agents::Tool
private
- PRIVATE_IP_RANGES = [
- IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
- IPAddr.new('10.0.0.0/8'), # IPv4 Private network
- IPAddr.new('172.16.0.0/12'), # IPv4 Private network
- IPAddr.new('192.168.0.0/16'), # IPv4 Private network
- IPAddr.new('169.254.0.0/16'), # IPv4 Link-local
- IPAddr.new('::1'), # IPv6 Loopback
- IPAddr.new('fc00::/7'), # IPv6 Unique local addresses
- IPAddr.new('fe80::/10') # IPv6 Link-local
- ].freeze
-
# Limit response size to prevent memory exhaustion and match LLM token limits
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
MAX_RESPONSE_SIZE = 1.megabyte
+ # Route through SafeFetch so custom tool requests share the app's centralized HTTP
+ # fetching (resolution, timeouts, response size limits, and redirect handling).
def execute_http_request(url, body, tool_context)
- uri = URI.parse(url)
+ json_body = body if @custom_tool.http_method == 'POST'
- # Check if resolved IP is private
- check_private_ip!(uri.host)
-
- http = Net::HTTP.new(uri.host, uri.port)
- http.use_ssl = uri.scheme == 'https'
- http.read_timeout = 30
- http.open_timeout = 10
- http.max_retries = 0 # Disable redirects
-
- request = build_http_request(uri, body)
- apply_authentication(request)
- apply_metadata_headers(request, tool_context)
-
- response = http.request(request)
-
- raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
-
- validate_response!(response)
-
- response
+ response_body = +''
+ SafeFetch.fetch(
+ url,
+ method: @custom_tool.http_method == 'POST' ? :post : :get,
+ body: json_body,
+ headers: request_headers(tool_context, json_body),
+ http_basic_authentication: @custom_tool.build_basic_auth_credentials,
+ max_bytes: MAX_RESPONSE_SIZE,
+ validate_content_type: false
+ ) { |result| response_body = result.tempfile.read }
+ response_body
end
- def check_private_ip!(hostname)
- ip_address = IPAddr.new(Resolv.getaddress(hostname))
-
- raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) }
- rescue Resolv::ResolvError, SocketError => e
- raise "DNS resolution failed: #{e.message}"
- end
-
- def validate_response!(response)
- content_length = response['content-length']&.to_i
- if content_length && content_length > MAX_RESPONSE_SIZE
- raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
- end
-
- return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE
-
- raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
- end
-
- def build_http_request(uri, body)
- if @custom_tool.http_method == 'POST'
- request = Net::HTTP::Post.new(uri.request_uri)
- if body
- request.body = body
- request['Content-Type'] = 'application/json'
- end
- else
- request = Net::HTTP::Get.new(uri.request_uri)
- end
- request
- end
-
- def apply_authentication(request)
+ def request_headers(tool_context, json_body)
headers = @custom_tool.build_auth_headers
- headers.each { |key, value| request[key] = value }
-
- credentials = @custom_tool.build_basic_auth_credentials
- request.basic_auth(*credentials) if credentials
- end
-
- def apply_metadata_headers(request, tool_context)
- state = tool_context&.state || {}
- metadata_headers = @custom_tool.build_metadata_headers(state)
- metadata_headers.each { |key, value| request[key] = value }
+ headers.merge!(@custom_tool.build_metadata_headers(tool_context&.state || {}))
+ headers['Content-Type'] = 'application/json' if json_body.present?
+ headers
end
end
From f93f2067b6fed98a8b8b8e9b20b359314da548b7 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Wed, 10 Jun 2026 14:52:30 +0530
Subject: [PATCH 06/22] fix(whatsapp): Drop obsolete WABA scope check broken by
Meta embedded signup (#14697)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description
Fixes WhatsApp embedded signup failing with "No WABA scope found in
token." Meta recently changed which scopes embedded-signup tokens carry
(whatsapp_business_manage_events instead of
whatsapp_business_management), which tripped a brittle scope-string
check. That check was redundant anyway — PhoneInfoService already
verifies the token's access to the specific WABA by calling its
/phone_numbers endpoint right before it. This removes the obsolete
TokenValidationService and relies on the functional check.
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
Co-authored-by: Muhsin Keloth
---
.../whatsapp/embedded_signup_service.rb | 5 -
.../whatsapp/token_validation_service.rb | 42 --------
.../whatsapp/embedded_signup_service_spec.rb | 5 -
.../whatsapp/token_validation_service_spec.rb | 99 -------------------
4 files changed, 151 deletions(-)
delete mode 100644 app/services/whatsapp/token_validation_service.rb
delete mode 100644 spec/services/whatsapp/token_validation_service_spec.rb
diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb
index 1a5b13c8f..2e8a5028e 100644
--- a/app/services/whatsapp/embedded_signup_service.rb
+++ b/app/services/whatsapp/embedded_signup_service.rb
@@ -13,7 +13,6 @@ class Whatsapp::EmbeddedSignupService
access_token = exchange_code_for_token
phone_info = fetch_phone_info(access_token)
- validate_token_access(access_token)
channel = create_or_reauthorize_channel(access_token, phone_info)
# NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because:
@@ -42,10 +41,6 @@ class Whatsapp::EmbeddedSignupService
Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
end
- def validate_token_access(access_token)
- Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
- end
-
def create_or_reauthorize_channel(access_token, phone_info)
if @inbox_id.present?
Whatsapp::ReauthorizationService.new(
diff --git a/app/services/whatsapp/token_validation_service.rb b/app/services/whatsapp/token_validation_service.rb
deleted file mode 100644
index 863f9da51..000000000
--- a/app/services/whatsapp/token_validation_service.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-class Whatsapp::TokenValidationService
- def initialize(access_token, waba_id)
- @access_token = access_token
- @waba_id = waba_id
- @api_client = Whatsapp::FacebookApiClient.new(access_token)
- end
-
- def perform
- validate_parameters!
- validate_token_waba_access
- end
-
- private
-
- def validate_parameters!
- raise ArgumentError, 'Access token is required' if @access_token.blank?
- raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
- end
-
- def validate_token_waba_access
- token_debug_data = @api_client.debug_token(@access_token)
- waba_scope = extract_waba_scope(token_debug_data)
- verify_waba_authorization(waba_scope)
- end
-
- def extract_waba_scope(token_data)
- granular_scopes = token_data.dig('data', 'granular_scopes')
- waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
-
- raise 'No WABA scope found in token' unless waba_scope
-
- waba_scope
- end
-
- def verify_waba_authorization(waba_scope)
- authorized_waba_ids = waba_scope['target_ids'] || []
-
- return if authorized_waba_ids.include?(@waba_id)
-
- raise "Token does not have access to WABA #{@waba_id}. Authorized WABAs: #{authorized_waba_ids}"
- end
-end
diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb
index a20e36ac8..560b1993e 100644
--- a/spec/services/whatsapp/embedded_signup_service_spec.rb
+++ b/spec/services/whatsapp/embedded_signup_service_spec.rb
@@ -36,11 +36,6 @@ describe Whatsapp::EmbeddedSignupService do
.with(params[:waba_id], params[:phone_number_id], access_token).and_return(phone_service)
allow(phone_service).to receive(:perform).and_return(phone_info)
- validation_service = instance_double(Whatsapp::TokenValidationService)
- allow(Whatsapp::TokenValidationService).to receive(:new)
- .with(access_token, params[:waba_id]).and_return(validation_service)
- allow(validation_service).to receive(:perform)
-
channel_creation = instance_double(Whatsapp::ChannelCreationService)
allow(Whatsapp::ChannelCreationService).to receive(:new)
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
diff --git a/spec/services/whatsapp/token_validation_service_spec.rb b/spec/services/whatsapp/token_validation_service_spec.rb
deleted file mode 100644
index d9cf40257..000000000
--- a/spec/services/whatsapp/token_validation_service_spec.rb
+++ /dev/null
@@ -1,99 +0,0 @@
-require 'rails_helper'
-
-describe Whatsapp::TokenValidationService do
- let(:access_token) { 'test_access_token' }
- let(:waba_id) { 'test_waba_id' }
- let(:service) { described_class.new(access_token, waba_id) }
- let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
-
- before do
- allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
- end
-
- describe '#perform' do
- context 'when token has access to WABA' do
- let(:debug_response) do
- {
- 'data' => {
- 'granular_scopes' => [
- {
- 'scope' => 'whatsapp_business_management',
- 'target_ids' => [waba_id, 'another_waba_id']
- }
- ]
- }
- }
- end
-
- before do
- allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
- end
-
- it 'validates successfully' do
- expect { service.perform }.not_to raise_error
- end
- end
-
- context 'when token does not have access to WABA' do
- let(:debug_response) do
- {
- 'data' => {
- 'granular_scopes' => [
- {
- 'scope' => 'whatsapp_business_management',
- 'target_ids' => ['different_waba_id']
- }
- ]
- }
- }
- end
-
- before do
- allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
- end
-
- it 'raises an error' do
- expect { service.perform }.to raise_error(/Token does not have access to WABA/)
- end
- end
-
- context 'when no WABA scope is found' do
- let(:debug_response) do
- {
- 'data' => {
- 'granular_scopes' => [
- {
- 'scope' => 'some_other_scope',
- 'target_ids' => ['some_id']
- }
- ]
- }
- }
- end
-
- before do
- allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
- end
-
- it 'raises an error' do
- expect { service.perform }.to raise_error('No WABA scope found in token')
- end
- end
-
- context 'when access_token is blank' do
- let(:access_token) { '' }
-
- it 'raises ArgumentError' do
- expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
- end
- end
-
- context 'when waba_id is blank' do
- let(:waba_id) { '' }
-
- it 'raises ArgumentError' do
- expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
- end
- end
- end
-end
From d9c07fe2e9c2e133423b96b5e9ae4c7d6618abbc Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Wed, 10 Jun 2026 16:24:37 +0530
Subject: [PATCH 07/22] feat: expose onboarding help center generation status
(#14671)
This PR adds a reload-safe onboarding Help Center generation status
path. Previously, generation progress was pushed through ActionCable,
which meant the onboarding UI could lose context after a page reload or
missed websocket event. The new endpoint exposes the current generation
id, raw Redis generation state, and Help Center article/category counts
so clients can recover state by fetching from the backend.
**What changed**
- Added an onboarding Help Center generation status endpoint.
- Persisted `help_center_generation_id` when generation is enqueued.
- Removed Help Center generation ActionCable broadcasts completely.
- Kept generation progress in Redis as the backend source of truth.
- Kept Help Center generation out of the onboarding hot path; the
existing onboarding controller does not start generation yet.
**How to test**
1. Start Help Center generation for an account.
2. Fetch the onboarding generation status endpoint and verify it returns
the generation id, Redis state, and article/category counts.
3. Reload the onboarding UI or client state and fetch again to confirm
progress can be recovered without relying on websocket events.
4. Verify skipped/completed generation states are reflected from Redis.
## Related PRs
- https://github.com/chatwoot/chatwoot/pull/14569
- https://github.com/chatwoot/chatwoot/pull/14568
- https://github.com/chatwoot/chatwoot/pull/14567
- https://github.com/chatwoot/chatwoot/pull/14619
- https://github.com/chatwoot/chatwoot/pull/14565 (Primary onboarding
PR)
---
.../api/v1/accounts/onboardings_controller.rb | 15 ++++++
app/javascript/dashboard/api/onboarding.js | 4 ++
.../api/v1/models/_account.json.jbuilder | 3 ++
config/routes.rb | 4 +-
.../api/v1/accounts/onboardings_controller.rb | 38 +++++++++++++
.../help_center_article_generation_job.rb | 13 ++---
.../help_center_article_writer_job.rb | 34 ++++--------
.../onboarding/help_center_broadcaster.rb | 29 ----------
.../help_center_creation_service.rb | 1 +
.../accounts/onboardings_controller_spec.rb | 36 +++++++++++++
.../builders/saml_user_builder_spec.rb | 8 +--
.../accounts/onboardings_controller_spec.rb | 42 +++++++++++++++
...help_center_article_generation_job_spec.rb | 27 ++--------
.../help_center_article_writer_job_spec.rb | 54 +++----------------
spec/jobs/mutex_application_job_spec.rb | 4 +-
15 files changed, 174 insertions(+), 138 deletions(-)
create mode 100644 enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb
delete mode 100644 enterprise/app/services/onboarding/help_center_broadcaster.rb
create mode 100644 spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb
diff --git a/app/controllers/api/v1/accounts/onboardings_controller.rb b/app/controllers/api/v1/accounts/onboardings_controller.rb
index 77c6245ea..181e4965e 100644
--- a/app/controllers/api/v1/accounts/onboardings_controller.rb
+++ b/app/controllers/api/v1/accounts/onboardings_controller.rb
@@ -16,6 +16,10 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
render 'api/v1/accounts/update', format: :json
end
+ def help_center_generation
+ render json: help_center_generation_status
+ end
+
private
def finalizing_account_details?
@@ -33,4 +37,15 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end
+
+ def help_center_generation_status
+ {
+ generation_id: nil,
+ state: nil,
+ articles_count: 0,
+ categories_count: 0
+ }
+ end
end
+
+Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
diff --git a/app/javascript/dashboard/api/onboarding.js b/app/javascript/dashboard/api/onboarding.js
index 2bc098eef..e15d16da3 100644
--- a/app/javascript/dashboard/api/onboarding.js
+++ b/app/javascript/dashboard/api/onboarding.js
@@ -9,6 +9,10 @@ class OnboardingAPI extends ApiClient {
update(data) {
return axios.patch(this.url, data);
}
+
+ getHelpCenterGeneration() {
+ return axios.get(`${this.url}/help_center_generation`);
+ }
}
export default new OnboardingAPI();
diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder
index 7506592ba..02b3480d5 100644
--- a/app/views/api/v1/models/_account.json.jbuilder
+++ b/app/views/api/v1/models/_account.json.jbuilder
@@ -14,6 +14,9 @@ if resource.custom_attributes.present?
json.referral_source resource.custom_attributes['referral_source'] if resource.custom_attributes['referral_source'].present?
json.brand_info resource.custom_attributes['brand_info'] if resource.custom_attributes['brand_info'].present?
json.onboarding_step resource.onboarding_step if resource.onboarding_step.present?
+ if resource.custom_attributes['help_center_generation_id'].present?
+ json.help_center_generation_id resource.custom_attributes['help_center_generation_id']
+ end
json.marked_for_deletion_at resource.custom_attributes['marked_for_deletion_at'] if resource.custom_attributes['marked_for_deletion_at'].present?
if resource.custom_attributes['marked_for_deletion_reason'].present?
json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason']
diff --git a/config/routes.rb b/config/routes.rb
index 351808744..d78831343 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -55,7 +55,9 @@ Rails.application.routes.draw do
resource :contact_merge, only: [:create]
end
resource :bulk_actions, only: [:create]
- resource :onboarding, only: [:update]
+ resource :onboarding, only: [:update] do
+ get :help_center_generation
+ end
resources :agents, only: [:index, :create, :update, :destroy] do
post :bulk_create, on: :collection
end
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb
new file mode 100644
index 000000000..1311bc3fc
--- /dev/null
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb
@@ -0,0 +1,38 @@
+module Enterprise::Api::V1::Accounts::OnboardingsController
+ def help_center_generation
+ @account = Current.account
+ render json: help_center_generation_status
+ end
+
+ private
+
+ def help_center_generation_status
+ generation_id = help_center_generation_id
+ return super if generation_id.blank?
+
+ state = Onboarding::HelpCenterGenerationState.current(generation_id)
+
+ {
+ generation_id: generation_id,
+ state: state,
+ articles_count: articles_count,
+ categories_count: categories_count
+ }
+ end
+
+ def help_center_generation_id
+ @account.custom_attributes['help_center_generation_id']
+ end
+
+ def articles_count
+ onboarding_portal&.articles&.count || 0
+ end
+
+ def categories_count
+ onboarding_portal&.categories&.count || 0
+ end
+
+ def onboarding_portal
+ @onboarding_portal ||= @account.portals.first
+ end
+end
diff --git a/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb
index ed561d668..b5f7eb247 100644
--- a/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb
+++ b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb
@@ -2,10 +2,10 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
queue_as :low
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
- _account_id, _portal_id, user_id, generation_id = job.arguments
+ _account_id, _portal_id, _user_id, generation_id = job.arguments
reason = "firecrawl exhausted: #{error.message}"
Rails.logger.warn "[HelpCenterGenerationJob] gen=#{generation_id} #{reason}"
- job.send(:skip_and_broadcast, user: User.find_by(id: user_id), generation_id: generation_id, reason: reason)
+ job.send(:skip_generation, generation_id: generation_id, reason: reason)
end
def perform(account_id, portal_id, user_id, generation_id)
@@ -19,7 +19,7 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
)
rescue Onboarding::HelpCenterErrors::CurationSkipped => e
Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}"
- skip_and_broadcast(user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message)
+ skip_generation(generation_id: generation_id, reason: e.message)
end
private
@@ -89,15 +89,12 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
def enqueue_writer_jobs(account_id:, portal_id:, user_id:, generation_id:, articles:)
articles.each do |article|
Onboarding::HelpCenterArticleWriterJob.perform_later(
- account_id, portal_id, user_id, generation_id, { article: article }
+ account_id, portal_id, user_id, generation_id, article
)
end
end
- def skip_and_broadcast(user:, generation_id:, reason:)
+ def skip_generation(generation_id:, reason:)
Onboarding::HelpCenterGenerationState.skip(generation_id, reason: reason)
- Onboarding::HelpCenterBroadcaster.completed(
- user: user, generation_id: generation_id, status: 'skipped', skip_reason: reason
- )
end
end
diff --git a/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb b/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb
index 2c25b86e9..68c218a19 100644
--- a/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb
+++ b/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb
@@ -9,43 +9,27 @@ class Onboarding::HelpCenterArticleWriterJob < ApplicationJob
job.send(:on_writer_failure, error)
end
- def perform(account_id, portal_id, user_id, generation_id, article_payload)
- user = User.find(user_id)
- payload = article_payload.with_indifferent_access
- article = Onboarding::HelpCenterArticleBuilder.new(
+ def perform(account_id, portal_id, user_id, generation_id, article)
+ Onboarding::HelpCenterArticleBuilder.new(
account: Account.find(account_id),
portal: Portal.find(portal_id),
- user: user,
- article: payload[:article]
+ user: User.find(user_id),
+ article: article
).perform
- finalize(user: user, generation_id: generation_id, article: article)
+ finalize(generation_id: generation_id)
end
private
def on_writer_failure(error)
- user, generation_id = failure_context
+ generation_id = arguments[3]
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}"
- finalize(user: user, generation_id: generation_id, article: nil)
+ finalize(generation_id: generation_id)
end
- def failure_context
- _account_id, _portal_id, user_id, generation_id = arguments
- [User.find_by(id: user_id), generation_id]
- end
-
- def finalize(user:, generation_id:, article:)
- result = Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
-
- if article
- Onboarding::HelpCenterBroadcaster.article_generated(
- user: user, generation_id: generation_id, article: article, articles_finished: result[:finished]
- )
- end
- return unless result[:completed]
-
- Onboarding::HelpCenterBroadcaster.completed(user: user, generation_id: generation_id, status: 'completed')
+ def finalize(generation_id:)
+ Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
rescue Onboarding::HelpCenterGenerationState::Missing => e
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}"
end
diff --git a/enterprise/app/services/onboarding/help_center_broadcaster.rb b/enterprise/app/services/onboarding/help_center_broadcaster.rb
deleted file mode 100644
index e12ed4287..000000000
--- a/enterprise/app/services/onboarding/help_center_broadcaster.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-module Onboarding::HelpCenterBroadcaster
- ARTICLE_GENERATED = 'help_center.article_generated'.freeze
- GENERATION_COMPLETED = 'help_center.generation_completed'.freeze
-
- module_function
-
- def article_generated(user:, generation_id:, article:, articles_finished:)
- broadcast(user, ARTICLE_GENERATED, {
- generation_id: generation_id,
- article_id: article.id,
- articles_finished: articles_finished
- })
- end
-
- def completed(user:, generation_id:, status:, skip_reason: nil)
- broadcast(user, GENERATION_COMPLETED, {
- generation_id: generation_id,
- status: status,
- skip_reason: skip_reason
- })
- end
-
- def broadcast(user, event, payload)
- token = user&.pubsub_token
- return if token.blank?
-
- ActionCableBroadcastJob.perform_later([token], event, payload)
- end
-end
diff --git a/enterprise/app/services/onboarding/help_center_creation_service.rb b/enterprise/app/services/onboarding/help_center_creation_service.rb
index 7ccd9bff3..9bbf053b5 100644
--- a/enterprise/app/services/onboarding/help_center_creation_service.rb
+++ b/enterprise/app/services/onboarding/help_center_creation_service.rb
@@ -77,6 +77,7 @@ class Onboarding::HelpCenterCreationService
generation_id = SecureRandom.uuid
Onboarding::HelpCenterArticleGenerationJob.perform_later(@account.id, portal.id, @user.id, generation_id)
+ @account.update!(custom_attributes: @account.custom_attributes.merge('help_center_generation_id' => generation_id))
rescue StandardError => e
Rails.logger.error "[HelpCenterCreation] Failed to enqueue article generation for account #{@account.id}: #{e.class} - #{e.message}"
end
diff --git a/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb
index ec2b4dcaa..6f118624b 100644
--- a/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb
@@ -111,4 +111,40 @@ RSpec.describe 'Onboarding API', type: :request do
end
end
end
+
+ describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation", as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as an agent (non-admin)' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
+ headers: agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when no help center generation has started' do
+ it 'returns not_started with zero counts' do
+ get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
+ headers: admin.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body).to include(
+ 'generation_id' => nil,
+ 'state' => nil,
+ 'articles_count' => 0,
+ 'categories_count' => 0
+ )
+ end
+ end
+ end
end
diff --git a/spec/enterprise/builders/saml_user_builder_spec.rb b/spec/enterprise/builders/saml_user_builder_spec.rb
index 9ebaf3a55..d3f3cb601 100644
--- a/spec/enterprise/builders/saml_user_builder_spec.rb
+++ b/spec/enterprise/builders/saml_user_builder_spec.rb
@@ -122,8 +122,8 @@ RSpec.describe SamlUserBuilder do
it 'does not add the user to the target account' do
expect do
builder.perform
- rescue SamlUserBuilder::AuthenticationFailed
- nil
+ rescue StandardError => e
+ raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
end.not_to change(AccountUser, :count)
expect(existing_user.reload.accounts).not_to include(account)
end
@@ -131,8 +131,8 @@ RSpec.describe SamlUserBuilder do
it 'does not convert the user provider to saml' do
expect do
builder.perform
- rescue SamlUserBuilder::AuthenticationFailed
- nil
+ rescue StandardError => e
+ raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
end.not_to(change { existing_user.reload.provider })
end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb
new file mode 100644
index 000000000..59d0564fa
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb
@@ -0,0 +1,42 @@
+require 'rails_helper'
+
+RSpec.describe 'Enterprise Onboarding API', type: :request do
+ let(:account) { create(:account, domain: 'example.com') }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+
+ describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
+ context 'when help center generation is in progress' do
+ let(:generation_id) { 'generation-123' }
+ let!(:portal) { create(:portal, account_id: account.id) }
+ let!(:category) { create(:category, portal: portal, account_id: account.id) }
+
+ before do
+ account.update!(custom_attributes: { 'help_center_generation_id' => generation_id })
+ create(:article, portal: portal, category: category, account_id: account.id, author_id: admin.id)
+ Onboarding::HelpCenterGenerationState.start(generation_id, total: 3)
+ Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
+ end
+
+ after do
+ Redis::Alfred.delete(Onboarding::HelpCenterGenerationState.key(generation_id))
+ end
+
+ it 'returns Redis state and help center counts' do
+ get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
+ headers: admin.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body).to include(
+ 'generation_id' => generation_id,
+ 'articles_count' => 1,
+ 'categories_count' => 1
+ )
+ expect(response.parsed_body['state']).to include(
+ 'status' => 'generating',
+ 'finished' => '1',
+ 'total' => '3'
+ )
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb b/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb
index 62529866d..b281f0c10 100644
--- a/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb
+++ b/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb
@@ -53,11 +53,9 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
admin.id,
generation_id,
hash_including(
- 'article' => hash_including(
- 'title' => 'Hello',
- 'urls' => ['https://x.test/a'],
- 'category_id' => portal.categories.first.id
- )
+ 'title' => 'Hello',
+ 'urls' => ['https://x.test/a'],
+ 'category_id' => portal.categories.first.id
)
)
)
@@ -83,7 +81,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
expect(writer_jobs.size).to eq(1)
expect(writer_jobs.first['arguments']).to include(
- hash_including('article' => hash_including('title' => 'Valid'))
+ hash_including('title' => 'Valid')
)
end
end
@@ -106,7 +104,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
expect(writer_jobs.size).to eq(1)
expect(writer_jobs.first['arguments']).to include(
- hash_including('article' => hash_including('title' => 'Approved', 'urls' => ['https://x.test/a']))
+ hash_including('title' => 'Approved', 'urls' => ['https://x.test/a'])
)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1')
end
@@ -170,19 +168,4 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
expect(state['skip_reason']).to include('firecrawl exhausted')
end
end
-
- describe 'broadcasts' do
- it 'broadcasts generation_completed with status: skipped on CurationSkipped' do
- curator = instance_double(Onboarding::HelpCenterCurator)
- allow(curator).to receive(:perform).and_raise(
- Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
- )
- allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
-
- payload = hash_including(generation_id: generation_id, status: 'skipped', skip_reason: 'no website url')
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.generation_completed', payload)
- end
- end
end
diff --git a/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb b/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb
index a4db6b1d3..b01ec35e3 100644
--- a/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb
+++ b/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb
@@ -6,8 +6,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
let!(:admin) { create(:user, account: account, role: :administrator) }
let(:generation_id) { 'generation-123' }
let(:article_spec) { { 'urls' => ['https://x.test/a'], 'title' => 'A', 'category_id' => nil } }
- let(:article_payload) { { 'article' => article_spec } }
- let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_payload] }
+ let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_spec] }
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
before do
@@ -68,16 +67,14 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
end
- it 'broadcasts completion when the final writer fails with ArticleBuildFailed' do
+ it 'marks generation completed when the final writer fails with ArticleBuildFailed' do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
)
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
- payload = hash_including(generation_id: generation_id, status: 'completed')
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.generation_completed', payload)
+ described_class.perform_now(*job_args)
+
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
'status' => 'completed', 'finished' => '2'
)
@@ -105,7 +102,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
end
end
- describe 'broadcasts' do
+ describe 'missing state' do
let(:built_article) { instance_double(Article, id: 9876) }
before do
@@ -113,47 +110,10 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
end
- it 'broadcasts help_center.article_generated on success' do
- payload = hash_including(generation_id: generation_id, article_id: 9876, articles_finished: 1)
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.article_generated', payload)
- end
-
- it 'broadcasts help_center.generation_completed when the last writer finishes' do
- described_class.perform_now(*job_args)
- payload = hash_including(generation_id: generation_id, status: 'completed')
-
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.generation_completed', payload)
- end
-
- it 'does not broadcast article_generated on builder failure' do
- allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
- Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
- )
-
- expect { described_class.perform_now(*job_args) }
- .not_to have_enqueued_job(ActionCableBroadcastJob)
- .with(anything, 'help_center.article_generated', anything)
- end
-
- it 'broadcasts generation_completed on late retries past total' do
- described_class.perform_now(*job_args)
- described_class.perform_now(*job_args)
- clear_enqueued_jobs
-
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.generation_completed', hash_including(generation_id: generation_id))
- end
-
- it 'skips progress broadcasts when state is missing' do
+ it 'does not raise when state is missing' do
Redis::Alfred.delete(state_key)
- expect { described_class.perform_now(*job_args) }
- .not_to have_enqueued_job(ActionCableBroadcastJob)
+ expect { described_class.perform_now(*job_args) }.not_to raise_error
end
end
end
diff --git a/spec/jobs/mutex_application_job_spec.rb b/spec/jobs/mutex_application_job_spec.rb
index 4c8fa3394..e2c03c55a 100644
--- a/spec/jobs/mutex_application_job_spec.rb
+++ b/spec/jobs/mutex_application_job_spec.rb
@@ -58,7 +58,7 @@ RSpec.describe MutexApplicationJob do
describe '.retry_on_lock_conflict' do
let(:job_class) do
- Class.new(described_class) do
+ Class.new(MutexApplicationJob) do
retry_on_lock_conflict wait: 1.second, attempts: 1, on_exhaustion: :process_without_lock
attr_reader :fallback_args
@@ -90,7 +90,7 @@ RSpec.describe MutexApplicationJob do
context 'without an exhaustion handler' do
let(:job_class) do
- Class.new(described_class) do
+ Class.new(MutexApplicationJob) do
retry_on_lock_conflict wait: 1.second, attempts: 1
def perform(lock_key)
From e6dfb91fcc40ea0df3f36cb50883859db4f7b4bc Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Wed, 10 Jun 2026 16:26:24 +0530
Subject: [PATCH 08/22] refactor: use SafeFetch for website branding page fetch
(#14693)
---
app/services/website_branding_service.rb | 16 +++++++++++-----
1 file changed, 11 insertions(+), 5 deletions(-)
diff --git a/app/services/website_branding_service.rb b/app/services/website_branding_service.rb
index 026b51e0e..178994d1e 100644
--- a/app/services/website_branding_service.rb
+++ b/app/services/website_branding_service.rb
@@ -37,12 +37,18 @@ class WebsiteBrandingService
private
def fetch_page
- response = HTTParty.get(@url, follow_redirects: true, timeout: 15)
- @http_status = response.code
- return nil unless response.success?
+ body = nil
+ SafeFetch.fetch(@url, validate_content_type: false) do |result|
+ body = result.tempfile.read
+ end
+ @http_status = 200
+ return nil if body.blank?
- Nokogiri::HTML(response.body)
- rescue StandardError => e
+ Nokogiri::HTML(body)
+ rescue SafeFetch::HttpError => e
+ @http_status = e.message.to_i
+ nil
+ rescue SafeFetch::Error => e
Rails.logger.error "[WebsiteBranding] Failed to fetch #{@url}: #{e.message}"
nil
end
From bec795f764d805d2a49d4a27553ad819aabc84f3 Mon Sep 17 00:00:00 2001
From: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Date: Wed, 10 Jun 2026 22:43:22 +0530
Subject: [PATCH 09/22] Bump version to 4.14.2
---
VERSION_CW | 2 +-
config/app.yml | 2 +-
package.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/VERSION_CW b/VERSION_CW
index d2b9909a9..0fb7a35b6 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.14.1
+4.14.2
diff --git a/config/app.yml b/config/app.yml
index fec34cd07..c2494befa 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.14.1'
+ version: '4.14.2'
development:
<<: *shared
diff --git a/package.json b/package.json
index 41313c8b3..bc51bbd85 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.14.1",
+ "version": "4.14.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
From c6e5657ee52192112c9913d1de797bcd86b5be8a Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Thu, 11 Jun 2026 11:16:41 +0530
Subject: [PATCH 10/22] fix(call): emit assignee activity message when agent
answers a call (#14700)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Linear Ticket
- https://linear.app/chatwoot/issue/CW-7249/debug-assignment-log
## Description
When an agent answers an inbound WhatsApp call on an unassigned
conversation, the conversation is claimed for that agent — but no
"assigned" activity message or assignee-changed event was emitted, so
the assignment was invisible in the timeline (and notifications/live
assignee panel didn't update).
The claim ran before `update_conversation_call_status`, so that later
`update!` on the same conversation clobbered
`saved_change_to_assignee_id?` before `after_commit` fired. Moving the
claim to be the conversation's final write in the transaction restores
the activity message, the `ASSIGNEE_CHANGED` event, and the live
assignee update.
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
1. Open an unassigned WhatsApp-call conversation.
2. As an agent, answer an inbound call.
3. Before: the conversation is assigned to you, but no "self-assigned"
activity message appears. After: the activity message is created and the
assignee updates live.
## 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/services/whatsapp/call_service.rb | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/enterprise/app/services/whatsapp/call_service.rb b/enterprise/app/services/whatsapp/call_service.rb
index 93eba957c..743409839 100644
--- a/enterprise/app/services/whatsapp/call_service.rb
+++ b/enterprise/app/services/whatsapp/call_service.rb
@@ -9,7 +9,7 @@ class Whatsapp::CallService
call.with_lock do
transition_to_in_progress!
update_message_status('in_progress')
- update_conversation_call_status(call.display_status)
+ claim_conversation_and_set_call_status
broadcast(:accepted, accepted_by_agent_id: agent.id)
end
call
@@ -56,7 +56,6 @@ class Whatsapp::CallService
forward_answer_to_meta!
call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
- claim_conversation_for_agent
end
def forward_answer_to_meta!
@@ -64,9 +63,13 @@ class Whatsapp::CallService
invoke_provider!(:accept_call, sdp_answer)
end
- # Take ownership of the conversation if no one holds it; leave assignee alone otherwise (transfer via UI).
- def claim_conversation_for_agent
- call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
+ # Claim an unheld conversation and set call_status in one save so previous_changes carries both the
+ # assignee change (activity message + ASSIGNEE_CHANGED) and the call_status change (conversation.updated webhook).
+ def claim_conversation_and_set_call_status
+ conversation = call.conversation
+ attrs = { additional_attributes: (conversation.additional_attributes || {}).merge('call_status' => call.display_status) }
+ attrs[:assignee] = agent if conversation.assignee_id.blank?
+ conversation.update!(attrs)
end
# Raise on Meta failure (bool false or transport error) so callers bail before
From 4d3196b02fd7b1046ff22a71ea1d1059f69e3193 Mon Sep 17 00:00:00 2001
From: Sony Mathew
Date: Thu, 11 Jun 2026 13:05:52 +0530
Subject: [PATCH 11/22] feat: Show unread count for all conversations (#14627)
## Description
Adds the unread count for All Conversations to the left sidebar. The
unread counts API now returns an `all_count` aggregate based on the same
permission-scoped inbox counts already used for sidebar badges, and the
sidebar renders that backend-provided value on the All Conversations
item.
Fixes
[CW-7240](https://linear.app/chatwoot/issue/CW-7240/unread-count-for-all-conversations)
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
- `bundle exec rspec
spec/services/conversations/unread_counts/counter_spec.rb
spec/enterprise/services/conversations/unread_counts/counter_spec.rb
spec/controllers/api/v1/accounts/conversations_controller_spec.rb`
- `pnpm test
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js`
- `pnpm exec eslint
app/javascript/dashboard/components-next/sidebar/Sidebar.vue
app/javascript/dashboard/store/modules/conversationUnreadCounts.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js`
- `bundle exec rubocop
app/services/conversations/unread_counts/counter.rb
spec/services/conversations/unread_counts/counter_spec.rb
spec/enterprise/services/conversations/unread_counts/counter_spec.rb
spec/controllers/api/v1/accounts/conversations_controller_spec.rb`
## 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
- [ ] Any dependent changes have been merged and published in downstream
modules
Co-authored-by: Muhsin Keloth
---
.../components-next/sidebar/Sidebar.vue | 4 ++++
.../store/modules/conversationUnreadCounts.js | 14 ++++++++++++--
.../conversationUnreadCounts/actions.spec.js | 1 +
.../conversationUnreadCounts/getters.spec.js | 15 +++++++++++++++
.../conversationUnreadCounts/mutations.spec.js | 16 +++++++++++++++-
.../conversations/unread_counts/counter.rb | 7 +++++--
.../v1/accounts/conversations_controller_spec.rb | 1 +
.../conversations/unread_counts/counter_spec.rb | 5 ++++-
.../conversations/unread_counts/counter_spec.rb | 3 +++
9 files changed, 60 insertions(+), 6 deletions(-)
diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
index d9f523dfc..f71652ca0 100644
--- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
+++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
@@ -175,6 +175,9 @@ useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
+const allUnreadCount = useMapGetter(
+ 'conversationUnreadCounts/getAllUnreadCount'
+);
const getInboxUnreadCount = useMapGetter(
'conversationUnreadCounts/getInboxUnreadCount'
);
@@ -297,6 +300,7 @@ const menuItems = computed(() => {
{
name: 'All',
label: t('SIDEBAR.ALL_CONVERSATIONS'),
+ badgeCount: allUnreadCount.value,
activeOn: ['inbox_conversation'],
to: accountScopedRoute('home'),
},
diff --git a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
index 0503c0806..c03249311 100644
--- a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
+++ b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
@@ -2,15 +2,21 @@ import ConversationAPI from '../../api/conversations';
import types from '../mutation-types';
export const state = {
+ allCount: 0,
inboxes: {},
labels: {},
teams: {},
};
+const normalizeCount = count => {
+ const parsedCount = Number(count);
+ return Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0;
+};
+
const normalizeCounts = counts => {
return Object.entries(counts || {}).reduce((result, [id, count]) => {
- const parsedCount = Number(count);
- if (Number.isFinite(parsedCount) && parsedCount > 0) {
+ const parsedCount = normalizeCount(count);
+ if (parsedCount > 0) {
result[String(id)] = parsedCount;
}
@@ -19,6 +25,9 @@ const normalizeCounts = counts => {
};
export const getters = {
+ getAllUnreadCount($state) {
+ return $state.allCount;
+ },
getInboxUnreadCount: $state => inboxId => {
return $state.inboxes[String(inboxId)] || 0;
},
@@ -55,6 +64,7 @@ export const actions = {
export const mutations = {
[types.SET_CONVERSATION_UNREAD_COUNTS]($state, payload = {}) {
+ $state.allCount = normalizeCount(payload.all_count);
$state.inboxes = normalizeCounts(payload.inboxes);
$state.labels = normalizeCounts(payload.labels);
$state.teams = normalizeCounts(payload.teams);
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
index 3100cdd10..29fadc897 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
@@ -15,6 +15,7 @@ describe('#actions', () => {
describe('#get', () => {
it('commits unread counts when API is successful', async () => {
const payload = {
+ all_count: 2,
inboxes: { 1: '2' },
labels: { 3: 4 },
teams: { 5: 6 },
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
index a3e74fc37..9fe19e22d 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
@@ -3,6 +3,7 @@ import { getters } from '../../conversationUnreadCounts';
describe('#getters', () => {
it('returns inbox unread count by id', () => {
const state = {
+ allCount: 0,
inboxes: { 1: 2 },
labels: {},
teams: {},
@@ -15,6 +16,7 @@ describe('#getters', () => {
it('returns label unread count by id', () => {
const state = {
+ allCount: 0,
inboxes: {},
labels: { 3: 4 },
teams: {},
@@ -27,6 +29,7 @@ describe('#getters', () => {
it('returns team unread count by id', () => {
const state = {
+ allCount: 0,
inboxes: {},
labels: {},
teams: { 5: 6 },
@@ -37,8 +40,20 @@ describe('#getters', () => {
expect(getters.getTeamUnreadCount(state)(6)).toBe(0);
});
+ it('returns all unread count', () => {
+ const state = {
+ allCount: 7,
+ inboxes: {},
+ labels: {},
+ teams: {},
+ };
+
+ expect(getters.getAllUnreadCount(state)).toBe(7);
+ });
+
it('returns unread count maps', () => {
const state = {
+ allCount: 0,
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
index 3f7e2b1ec..8941d7430 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
@@ -4,9 +4,10 @@ import { mutations } from '../../conversationUnreadCounts';
describe('#mutations', () => {
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
it('normalizes unread count payload', () => {
- const state = { inboxes: {}, labels: {}, teams: {} };
+ const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} };
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
+ all_count: '3',
inboxes: {
1: '2',
2: 0,
@@ -23,6 +24,7 @@ describe('#mutations', () => {
});
expect(state).toEqual({
+ allCount: 3,
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
@@ -31,6 +33,7 @@ describe('#mutations', () => {
it('clears counts when payload is empty', () => {
const state = {
+ allCount: 2,
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
@@ -39,10 +42,21 @@ describe('#mutations', () => {
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
expect(state).toEqual({
+ allCount: 0,
inboxes: {},
labels: {},
teams: {},
});
});
+
+ it('normalizes invalid aggregate counts to zero', () => {
+ const state = { allCount: 2, inboxes: {}, labels: {}, teams: {} };
+
+ mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
+ all_count: 'invalid',
+ });
+
+ expect(state.allCount).toBe(0);
+ });
});
});
diff --git a/app/services/conversations/unread_counts/counter.rb b/app/services/conversations/unread_counts/counter.rb
index f4126b61f..b1ba5ddb0 100644
--- a/app/services/conversations/unread_counts/counter.rb
+++ b/app/services/conversations/unread_counts/counter.rb
@@ -19,8 +19,11 @@ class Conversations::UnreadCounts::Counter
ensure_base_cache!
ensure_assignment_cache! if assignment_mode?
+ inbox_counts = unread_inbox_counts
+
{
- inboxes: unread_inbox_counts,
+ all_count: inbox_counts.values.sum,
+ inboxes: inbox_counts,
labels: unread_label_counts,
teams: unread_team_counts
}
@@ -191,7 +194,7 @@ class Conversations::UnreadCounts::Counter
end
def empty_counts
- { inboxes: {}, labels: {}, teams: {} }
+ { all_count: 0, inboxes: {}, labels: {}, teams: {} }
end
def store
diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
index f8fd446d2..bdb8117ac 100644
--- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -141,6 +141,7 @@ RSpec.describe 'Conversations API', type: :request do
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']).to eq(
+ 'all_count' => 1,
'inboxes' => { visible_inbox.id.to_s => 1 },
'labels' => { label.id.to_s => 1 },
'teams' => {}
diff --git a/spec/enterprise/services/conversations/unread_counts/counter_spec.rb b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
index cbb2d6c98..e8f850e78 100644
--- a/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
+++ b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
@@ -26,6 +26,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
+ expect(result[:all_count]).to eq(2)
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
expect(result[:labels]).to eq(label.id.to_s => 2)
expect(result[:teams]).to eq(team.id.to_s => 2)
@@ -40,6 +41,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
+ expect(result[:all_count]).to eq(2)
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
expect(result[:labels]).to eq(label.id.to_s => 2)
expect(result[:teams]).to eq(team.id.to_s => 2)
@@ -53,6 +55,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
+ expect(result[:all_count]).to eq(1)
expect(result[:inboxes]).to eq(inbox.id.to_s => 1)
expect(result[:labels]).to eq(label.id.to_s => 1)
expect(result[:teams]).to eq(team.id.to_s => 1)
@@ -65,7 +68,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
- expect(result).to eq(inboxes: {}, labels: {}, teams: {})
+ expect(result).to eq(all_count: 0, inboxes: {}, labels: {}, teams: {})
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
end
diff --git a/spec/services/conversations/unread_counts/counter_spec.rb b/spec/services/conversations/unread_counts/counter_spec.rb
index bfd10436e..723f2d437 100644
--- a/spec/services/conversations/unread_counts/counter_spec.rb
+++ b/spec/services/conversations/unread_counts/counter_spec.rb
@@ -62,6 +62,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result).to eq(
+ all_count: 1,
inboxes: { visible_inbox.id.to_s => 1 },
labels: { label.id.to_s => 1 },
teams: { visible_team.id.to_s => 1 }
@@ -75,6 +76,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: admin).perform
expect(result).to eq(
+ all_count: 2,
inboxes: { visible_inbox.id.to_s => 1, hidden_inbox.id.to_s => 1 },
labels: { label.id.to_s => 2 },
teams: { visible_team.id.to_s => 2 }
@@ -87,6 +89,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result).to eq(
+ all_count: 1,
inboxes: { visible_inbox.id.to_s => 1 },
labels: {},
teams: { visible_team.id.to_s => 1 }
From 940428c10e5dd23e7dfb195038dd540231845bce Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Thu, 11 Jun 2026 13:47:08 +0530
Subject: [PATCH 12/22] fix: preserve markdown links with unsafe href
characters in help center content (#14686)
---
package.json | 2 +-
pnpm-lock.yaml | 10 +++++-----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/package.json b/package.json
index bc51bbd85..09eb084f5 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.19",
+ "@chatwoot/prosemirror-schema": "1.3.21",
"@chatwoot/utils": "^0.0.55",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 68e667953..e843f8b09 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,8 +25,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.3.19
- version: 1.3.19
+ specifier: 1.3.21
+ version: 1.3.21
'@chatwoot/utils':
specifier: ^0.0.55
version: 0.0.55
@@ -458,8 +458,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.3.19':
- resolution: {integrity: sha512-LbATIAeTzclvbIK6WjtrGUO37AtMWkzCJi+s/KpUIp81TORhc0fHnclGT3353AbxeH6dF51/4hFTTsmF+ziqGA==}
+ '@chatwoot/prosemirror-schema@1.3.21':
+ resolution: {integrity: sha512-Y/OfXH1orK14foRcMrUees8lDjnDS9qZylVMyrstbfNyP5hbkBofsGAi6SP+5oIbPZ8iSG0sJyXtscoXpu/OFw==}
'@chatwoot/utils@0.0.55':
resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
@@ -5128,7 +5128,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.3.19':
+ '@chatwoot/prosemirror-schema@1.3.21':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.7.1
From ce93ddec7876b1d291b8b2185e63ab0083376793 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Thu, 11 Jun 2026 14:09:13 +0530
Subject: [PATCH 13/22] fix: re-fetch widget conversation status on reconnect
(#14683)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When an inbox has **"Allow messages after conversation is resolved"**
disabled, the live-chat widget should hide the reply box once a
conversation is resolved — but users could still send a reply, silently
reopening it. This syncs the widget's conversation status on reconnect
so the reply box hides correctly.
## Closes
- CW-7272
## How to reproduce
1. Disable **Allow messages after conversation is resolved** on an
inbox.
2. Open the widget, then let its websocket drop (background tab / lose
network).
3. While disconnected, get the conversation resolved (e.g. auto-resolve
on inactivity).
4. Reconnect → reply box is still visible and a sent message reopens the
resolved conversation.
## Why
Reply-box hiding is gated on the widget's local status, updated via the
`conversation.status_changed` socket event. If the resolve happens while
disconnected, the event is missed — and on reconnect the widget only
re-synced messages, never the status, so it stayed stale at `open`.
## What changed
- `onReconnect` now also dispatches
`conversationAttributes/getAttributes` to refresh status after a missed
event.
- Added a spec covering the reconnect behavior.
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
---
app/javascript/widget/helpers/actionCable.js | 3 ++
.../widget/helpers/specs/actionCable.spec.js | 53 +++++++++++++++++++
2 files changed, 56 insertions(+)
create mode 100644 app/javascript/widget/helpers/specs/actionCable.spec.js
diff --git a/app/javascript/widget/helpers/actionCable.js b/app/javascript/widget/helpers/actionCable.js
index 60c379ed8..546317018 100644
--- a/app/javascript/widget/helpers/actionCable.js
+++ b/app/javascript/widget/helpers/actionCable.js
@@ -36,6 +36,9 @@ class ActionCableConnector extends BaseActionCableConnector {
onReconnect = () => {
this.syncLatestMessages();
+ // Re-fetch conversation attributes so a status change (e.g. auto-resolve)
+ // that happened while disconnected is reflected, keeping the reply box state correct.
+ this.app.$store.dispatch('conversationAttributes/getAttributes');
};
setLastMessageId = () => {
diff --git a/app/javascript/widget/helpers/specs/actionCable.spec.js b/app/javascript/widget/helpers/specs/actionCable.spec.js
new file mode 100644
index 000000000..6aa3872bf
--- /dev/null
+++ b/app/javascript/widget/helpers/specs/actionCable.spec.js
@@ -0,0 +1,53 @@
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
+import ActionCableConnector from '../actionCable';
+
+vi.mock('@rails/actioncable', () => ({
+ createConsumer: () => ({
+ subscriptions: { create: () => ({}) },
+ disconnect: vi.fn(),
+ }),
+}));
+
+describe('Widget ActionCableConnector', () => {
+ let app;
+ let mockDispatch;
+ let connector;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ mockDispatch = vi.fn();
+ app = {
+ $store: {
+ dispatch: mockDispatch,
+ getters: {
+ getCurrentAccountId: 1,
+ getCurrentUserID: 1,
+ },
+ },
+ };
+ connector = new ActionCableConnector(app, 'test-token');
+ mockDispatch.mockClear();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+ });
+
+ it('registers the conversation.status_changed event handler', () => {
+ expect(connector.events['conversation.status_changed']).toBe(
+ connector.onStatusChange
+ );
+ });
+
+ it('re-fetches conversation attributes on reconnect so a status change missed while disconnected is reflected', () => {
+ connector.onReconnect();
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ 'conversation/syncLatestMessages'
+ );
+ expect(mockDispatch).toHaveBeenCalledWith(
+ 'conversationAttributes/getAttributes'
+ );
+ });
+});
From 8d5d02ea972bbaa6d2e7bc445a8b902f94953e8c Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Thu, 11 Jun 2026 14:22:44 +0530
Subject: [PATCH 14/22] feat: add per-inbox toggle to disable incoming calls
(#14645)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description
Adds a per-inbox "Allow incoming calls" toggle for voice-enabled
WhatsApp and Twilio inboxes. When turned off, the setting is persisted
on the channel; actually rejecting inbound calls is handled in a
follow-up PR.
## Type of change
- [ ] New feature (non-breaking change which adds functionality)
## Screenshot
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
app/javascript/dashboard/api/inboxes.js | 6 +++
.../dashboard/i18n/locale/en/inboxMgmt.json | 4 ++
.../settingsPage/VoiceConfigurationPage.vue | 42 +++++++++++++++
.../settingsPage/WhatsappCallingPage.vue | 41 ++++++++++++++
app/models/channel/twilio_sms.rb | 6 +++
app/models/channel/whatsapp.rb | 5 ++
app/policies/inbox_policy.rb | 4 ++
app/views/api/v1/models/_inbox.json.jbuilder | 6 ++-
config/routes.rb | 1 +
...d_provider_config_to_channel_twilio_sms.rb | 5 ++
db/schema.rb | 3 +-
.../api/v1/accounts/inboxes_controller.rb | 28 ++++++++++
.../controllers/twilio/voice_controller.rb | 12 +++++
.../whatsapp/incoming_call_service.rb | 6 +++
.../v1/accounts/inboxes_controller_spec.rb | 53 +++++++++++++++++++
.../twilio/voice_controller_spec.rb | 17 ++++++
.../models/channel/twilio_sms_voice_spec.rb | 13 +++++
.../whatsapp/incoming_call_service_spec.rb | 14 +++++
spec/models/channel/whatsapp_spec.rb | 18 +++++++
19 files changed, 282 insertions(+), 2 deletions(-)
create mode 100644 db/migrate/20260604000000_add_provider_config_to_channel_twilio_sms.rb
diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js
index 114dbb6f4..3c1d17fc8 100644
--- a/app/javascript/dashboard/api/inboxes.js
+++ b/app/javascript/dashboard/api/inboxes.js
@@ -60,6 +60,12 @@ class Inboxes extends CacheEnabledApiClient {
disableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
}
+
+ setInboundCalls(inboxId, enabled) {
+ return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, {
+ inbound_calls_enabled: enabled,
+ });
+ }
}
export default new Inboxes();
diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
index cd7e46330..574cf5b9b 100644
--- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
+++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
@@ -653,6 +653,10 @@
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
+ },
+ "INBOUND": {
+ "LABEL": "Allow incoming calls",
+ "DESCRIPTION": "Let customers call this number. When turned off, incoming calls are declined automatically — agents aren't notified and no conversation is created. Agents can still place outgoing calls."
}
},
"WHATSAPP_CALLING": {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/VoiceConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/VoiceConfigurationPage.vue
index b0fe858e5..b5f3183f1 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/VoiceConfigurationPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/VoiceConfigurationPage.vue
@@ -1,9 +1,11 @@