From 3c67c415442ea115dc0b1bc142875ff43eb65695 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Fri, 22 May 2026 11:09:27 +0530
Subject: [PATCH 01/49] chore: support PFX filetype in attachment uploads
(#14456)
# Pull Request Template
## Description
This PR expands the default upload rules to support PFX certificate
files (`application/x-pkcs12`, `application/pkcs12`, `.pfx`) across
private notes, Website, Email, and Telegram channels.
Also adds `.xls` / `.xlsx` extension fallbacks for cases where browsers
upload Excel files with an empty or generic MIME type.
### Utils Repo PR: https://github.com/chatwoot/utils/pull/61
Fixes
https://linear.app/chatwoot/issue/CW-7085/support-more-file-types-in-private-notes-and-in-app
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
### Screenshots
## 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: aakashb95
---
.../components-next/icon/FileIcon.vue | 1 +
.../widgets/WootWriter/ReplyBottomPanel.vue | 10 +---------
app/javascript/shared/helpers/FileHelper.js | 4 +---
app/models/attachment.rb | 18 ++++++++++++++++--
package.json | 2 +-
pnpm-lock.yaml | 10 +++++-----
theme/icons.js | 17 +++++++++++++++++
7 files changed, 42 insertions(+), 20 deletions(-)
diff --git a/app/javascript/dashboard/components-next/icon/FileIcon.vue b/app/javascript/dashboard/components-next/icon/FileIcon.vue
index 8dd9e7ce1..d82be3e69 100644
--- a/app/javascript/dashboard/components-next/icon/FileIcon.vue
+++ b/app/javascript/dashboard/components-next/icon/FileIcon.vue
@@ -18,6 +18,7 @@ const fileTypeIcon = computed(() => {
json: 'i-woot-file-txt',
odt: 'i-woot-file-doc',
pdf: 'i-woot-file-pdf',
+ pfx: 'i-woot-file-pfx',
ppt: 'i-woot-file-ppt',
pptx: 'i-woot-file-ppt',
rar: 'i-woot-file-zip',
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
index ff569d763..2e5a9aab2 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
@@ -7,7 +7,6 @@ import * as ActiveStorage from 'activestorage';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
-import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
@@ -166,11 +165,6 @@ export default {
uploadRef,
};
},
- data() {
- return {
- ALLOWED_FILE_TYPES,
- };
- },
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
@@ -212,13 +206,11 @@ export default {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() {
- // Use default file types for private notes
if (this.isOnPrivateNote) {
- return this.ALLOWED_FILE_TYPES;
+ return getAllowedFileTypesByChannel();
}
let channelType = this.channelType || this.inbox?.channel_type;
-
if (this.isAnInstagramChannel || this.isInstagramDM) {
channelType = INBOX_TYPES.INSTAGRAM;
}
diff --git a/app/javascript/shared/helpers/FileHelper.js b/app/javascript/shared/helpers/FileHelper.js
index 2616c868a..93c8a7156 100644
--- a/app/javascript/shared/helpers/FileHelper.js
+++ b/app/javascript/shared/helpers/FileHelper.js
@@ -1,6 +1,5 @@
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
-import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
export const DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE = 40;
@@ -58,9 +57,8 @@ export const isFileTypeAllowedForChannel = (file, options = {}) => {
isOnPrivateNote,
} = options;
- // Use broader file types for private notes (matches file picker behavior)
const allowedFileTypes = isOnPrivateNote
- ? ALLOWED_FILE_TYPES
+ ? getAllowedFileTypesByChannel()
: getAllowedFileTypesByChannel({
channelType:
isInstagramChannel || conversationType === 'instagram_direct_message'
diff --git a/app/models/attachment.rb b/app/models/attachment.rb
index 2d46f3b7e..102d90beb 100644
--- a/app/models/attachment.rb
+++ b/app/models/attachment.rb
@@ -33,7 +33,10 @@ class Attachment < ApplicationRecord
application/vnd.openxmlformats-officedocument.presentationml.presentation
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
application/vnd.openxmlformats-officedocument.wordprocessingml.document
+ application/x-pkcs12 application/pkcs12
].freeze
+ ACCEPTABLE_FILE_EXTENSIONS = %w[pfx].freeze
+ GENERIC_FILE_CONTENT_TYPES = %w[application/octet-stream].freeze
belongs_to :account
belongs_to :message
has_one_attached :file
@@ -195,7 +198,10 @@ class Attachment < ApplicationRecord
end
def validate_file_content_type(file_content_type)
- errors.add(:file, 'type not supported') unless media_file?(file_content_type) || ACCEPTABLE_FILE_TYPES.include?(file_content_type)
+ return if media_file?(file_content_type) || ACCEPTABLE_FILE_TYPES.include?(file_content_type)
+ return if generic_file_content_type?(file_content_type) && ACCEPTABLE_FILE_EXTENSIONS.include?(file_extension)
+
+ errors.add(:file, 'type not supported')
end
def validate_file_size(byte_size)
@@ -206,7 +212,15 @@ class Attachment < ApplicationRecord
end
def media_file?(file_content_type)
- file_content_type.start_with?('image/', 'video/', 'audio/')
+ file_content_type.to_s.start_with?('image/', 'video/', 'audio/')
+ end
+
+ def generic_file_content_type?(file_content_type)
+ file_content_type.blank? || GENERIC_FILE_CONTENT_TYPES.include?(file_content_type)
+ end
+
+ def file_extension
+ File.extname(file.filename.to_s).delete_prefix('.').downcase
end
end
diff --git a/package.json b/package.json
index ed8bbf00f..1e506d6c5 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,7 @@
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.3.13",
- "@chatwoot/utils": "^0.0.52",
+ "@chatwoot/utils": "^0.0.55",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3f1e9e2cf..3a9efa7eb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,8 +29,8 @@ importers:
specifier: 1.3.13
version: 1.3.13
'@chatwoot/utils':
- specifier: ^0.0.52
- version: 0.0.52
+ specifier: ^0.0.55
+ version: 0.0.55
'@formkit/core':
specifier: ^1.7.2
version: 1.7.2
@@ -462,8 +462,8 @@ packages:
'@chatwoot/prosemirror-schema@1.3.13':
resolution: {integrity: sha512-T6FBUinMJbwDCD7975g8M/Tsn2+G3O2pTGIXdcLkMRpbAAC6mVdl4ZcZektlt5y/PVmPVqNHPsfee1XB/C3vAw==}
- '@chatwoot/utils@0.0.52':
- resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
+ '@chatwoot/utils@0.0.55':
+ resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -5011,7 +5011,7 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1
- '@chatwoot/utils@0.0.52':
+ '@chatwoot/utils@0.0.55':
dependencies:
date-fns: 2.30.0
diff --git a/theme/icons.js b/theme/icons.js
index 266c7ddfa..281e21c23 100644
--- a/theme/icons.js
+++ b/theme/icons.js
@@ -113,6 +113,23 @@ export const icons = {
width: 16,
height: 20,
},
+ 'file-pfx': {
+ body: `
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ width: 16,
+ height: 20,
+ },
bin: {
body: ``,
width: 16,
From 1d7a9093d227cc7d1627bda9408a050e78cd3d66 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Fri, 22 May 2026 11:33:19 +0530
Subject: [PATCH 02/49] fix: clarify agent availability swagger fields (#14533)
Clarifies the agent availability API documentation so request payloads
use the writable `availability` field, while `availability_status`
remains documented as a read-only response field.
## Closes
Closes #13873
## Why
The backend already supports updating an agent's configured availability
through `availability`, but the Swagger request payloads documented
`availability_status`. That made clients follow a read-only response
field and see successful requests without the intended availability
change.
## What changed
- Replaces `availability_status` with `availability` in agent
create/update request schemas.
- Updates the availability enum to `online`, `busy`, and `offline`.
- Marks response `availability_status` as read-only and explains that it
is derived from configured availability, auto-offline, and presence.
- Regenerates the combined and tag-group Swagger JSON files.
## Validation
- `bundle exec rails swagger:build`
- `bundle exec rspec spec/swagger/openapi_spec.rb`
- `git diff --check`
---
.../request/agent/create_payload.yml | 10 +++----
.../request/agent/update_payload.yml | 10 +++----
swagger/definitions/resource/agent.yml | 10 ++++---
swagger/swagger.json | 27 ++++++++++---------
swagger/tag_groups/application_swagger.json | 27 ++++++++++---------
swagger/tag_groups/client_swagger.json | 27 ++++++++++---------
swagger/tag_groups/other_swagger.json | 27 ++++++++++---------
swagger/tag_groups/platform_swagger.json | 27 ++++++++++---------
8 files changed, 87 insertions(+), 78 deletions(-)
diff --git a/swagger/definitions/request/agent/create_payload.yml b/swagger/definitions/request/agent/create_payload.yml
index 1daeae83a..77180d282 100644
--- a/swagger/definitions/request/agent/create_payload.yml
+++ b/swagger/definitions/request/agent/create_payload.yml
@@ -17,12 +17,12 @@ properties:
enum: ['agent', 'administrator']
description: Whether its administrator or agent
example: 'agent'
- availability_status:
+ availability:
type: string
- enum: ['available', 'busy', 'offline']
- description: The availability setting of the agent.
- example: 'available'
+ enum: ['online', 'busy', 'offline']
+ description: The configured availability of the agent.
+ example: 'online'
auto_offline:
type: boolean
- description: Whether the availability status of agent is configured to go offline automatically when away.
+ description: Whether the agent is automatically marked offline when they are away.
example: true
diff --git a/swagger/definitions/request/agent/update_payload.yml b/swagger/definitions/request/agent/update_payload.yml
index fc8d1457d..168d46f49 100644
--- a/swagger/definitions/request/agent/update_payload.yml
+++ b/swagger/definitions/request/agent/update_payload.yml
@@ -7,12 +7,12 @@ properties:
enum: ['agent', 'administrator']
description: Whether its administrator or agent
example: 'agent'
- availability_status:
+ availability:
type: string
- enum: ['available', 'busy', 'offline']
- description: The availability status of the agent.
- example: 'available'
+ enum: ['online', 'busy', 'offline']
+ description: The configured availability of the agent.
+ example: 'online'
auto_offline:
type: boolean
- description: Whether the availability status of agent is configured to go offline automatically when away.
+ description: Whether the agent is automatically marked offline when they are away.
example: true
diff --git a/swagger/definitions/resource/agent.yml b/swagger/definitions/resource/agent.yml
index 1d7b2b4c3..cabd1ee27 100644
--- a/swagger/definitions/resource/agent.yml
+++ b/swagger/definitions/resource/agent.yml
@@ -6,11 +6,15 @@ properties:
type: integer
availability_status:
type: string
- enum: ['available', 'busy', 'offline']
- description: The availability status of the agent computed by Chatwoot.
+ enum: ['online', 'busy', 'offline']
+ readOnly: true
+ description: >-
+ The effective availability status of the agent, derived from the configured availability,
+ auto-offline setting, and current presence. To update an agent's configured availability,
+ use the availability field in create or update requests.
auto_offline:
type: boolean
- description: Whether the availability status of agent is configured to go offline automatically when away.
+ description: Whether the agent is automatically marked offline when they are away.
confirmed:
type: boolean
description: Whether the agent has confirmed their email address.
diff --git a/swagger/swagger.json b/swagger/swagger.json
index 94d1f04d3..b8b64b009 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -9892,15 +9892,16 @@
"availability_status": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent computed by Chatwoot."
+ "readOnly": true,
+ "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests."
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away."
+ "description": "Whether the agent is automatically marked offline when they are away."
},
"confirmed": {
"type": "boolean",
@@ -11595,19 +11596,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability setting of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -11627,19 +11628,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json
index a013b3694..17e015139 100644
--- a/swagger/tag_groups/application_swagger.json
+++ b/swagger/tag_groups/application_swagger.json
@@ -8399,15 +8399,16 @@
"availability_status": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent computed by Chatwoot."
+ "readOnly": true,
+ "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests."
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away."
+ "description": "Whether the agent is automatically marked offline when they are away."
},
"confirmed": {
"type": "boolean",
@@ -10102,19 +10103,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability setting of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -10134,19 +10135,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json
index 763e090b1..7bc7227fb 100644
--- a/swagger/tag_groups/client_swagger.json
+++ b/swagger/tag_groups/client_swagger.json
@@ -1664,15 +1664,16 @@
"availability_status": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent computed by Chatwoot."
+ "readOnly": true,
+ "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests."
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away."
+ "description": "Whether the agent is automatically marked offline when they are away."
},
"confirmed": {
"type": "boolean",
@@ -3367,19 +3368,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability setting of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -3399,19 +3400,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json
index 50bf2212b..6dbfbdd8e 100644
--- a/swagger/tag_groups/other_swagger.json
+++ b/swagger/tag_groups/other_swagger.json
@@ -1079,15 +1079,16 @@
"availability_status": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent computed by Chatwoot."
+ "readOnly": true,
+ "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests."
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away."
+ "description": "Whether the agent is automatically marked offline when they are away."
},
"confirmed": {
"type": "boolean",
@@ -2782,19 +2783,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability setting of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -2814,19 +2815,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json
index f1e471e79..952813f62 100644
--- a/swagger/tag_groups/platform_swagger.json
+++ b/swagger/tag_groups/platform_swagger.json
@@ -1840,15 +1840,16 @@
"availability_status": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent computed by Chatwoot."
+ "readOnly": true,
+ "description": "The effective availability status of the agent, derived from the configured availability, auto-offline setting, and current presence. To update an agent's configured availability, use the availability field in create or update requests."
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away."
+ "description": "Whether the agent is automatically marked offline when they are away."
},
"confirmed": {
"type": "boolean",
@@ -3543,19 +3544,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability setting of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
@@ -3575,19 +3576,19 @@
"description": "Whether its administrator or agent",
"example": "agent"
},
- "availability_status": {
+ "availability": {
"type": "string",
"enum": [
- "available",
+ "online",
"busy",
"offline"
],
- "description": "The availability status of the agent.",
- "example": "available"
+ "description": "The configured availability of the agent.",
+ "example": "online"
},
"auto_offline": {
"type": "boolean",
- "description": "Whether the availability status of agent is configured to go offline automatically when away.",
+ "description": "Whether the agent is automatically marked offline when they are away.",
"example": true
}
}
From bef25781dede466ba4613fb88d4292faf4cdddb2 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Fri, 22 May 2026 11:55:16 +0530
Subject: [PATCH 03/49] feat(attachments): add XML and PFX file support
(#14539)
Update frontend allowed file types and FileIcon mapping, and backend
Attachment constants to accept .xml and .pfx files
# Pull Request Template
## Description
Customer also wanted XML support along with .pfx
Following up on #14456
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
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
---
app/javascript/dashboard/components-next/icon/FileIcon.vue | 1 +
app/javascript/shared/constants/messages.js | 4 +++-
app/models/attachment.rb | 5 +++--
3 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/app/javascript/dashboard/components-next/icon/FileIcon.vue b/app/javascript/dashboard/components-next/icon/FileIcon.vue
index d82be3e69..66a971bc6 100644
--- a/app/javascript/dashboard/components-next/icon/FileIcon.vue
+++ b/app/javascript/dashboard/components-next/icon/FileIcon.vue
@@ -27,6 +27,7 @@ const fileTypeIcon = computed(() => {
txt: 'i-woot-file-txt',
xls: 'i-woot-file-xls',
xlsx: 'i-woot-file-xls',
+ xml: 'i-woot-file-txt',
zip: 'i-woot-file-zip',
};
diff --git a/app/javascript/shared/constants/messages.js b/app/javascript/shared/constants/messages.js
index 989aa12ca..18bc380ec 100644
--- a/app/javascript/shared/constants/messages.js
+++ b/app/javascript/shared/constants/messages.js
@@ -39,12 +39,14 @@ export const ALLOWED_FILE_TYPES =
'audio/*,' +
'video/*,' +
'.3gpp,' +
+ '.xls, .xlsx, .xml, .pfx,' +
'text/csv, text/plain, application/json, application/pdf, text/rtf,' +
'application/xml, text/xml,' +
'application/zip, application/x-7z-compressed application/vnd.rar application/x-tar,' +
'application/msword, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/vnd.oasis.opendocument.text,' +
'application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,' +
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document,';
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document,' +
+ 'application/x-pkcs12, application/pkcs12,';
export const CSAT_RATINGS = [
{
diff --git a/app/models/attachment.rb b/app/models/attachment.rb
index 102d90beb..79a8021b3 100644
--- a/app/models/attachment.rb
+++ b/app/models/attachment.rb
@@ -25,8 +25,9 @@ class Attachment < ApplicationRecord
include Rails.application.routes.url_helpers
ACCEPTABLE_FILE_TYPES = %w[
- text/csv text/plain text/rtf
+ text/csv text/plain text/rtf text/xml
application/json application/pdf
+ application/xml
application/zip application/x-7z-compressed application/vnd.rar application/x-tar
application/msword application/vnd.ms-excel application/vnd.ms-powerpoint application/rtf
application/vnd.oasis.opendocument.text
@@ -35,7 +36,7 @@ class Attachment < ApplicationRecord
application/vnd.openxmlformats-officedocument.wordprocessingml.document
application/x-pkcs12 application/pkcs12
].freeze
- ACCEPTABLE_FILE_EXTENSIONS = %w[pfx].freeze
+ ACCEPTABLE_FILE_EXTENSIONS = %w[pfx xml].freeze
GENERIC_FILE_CONTENT_TYPES = %w[application/octet-stream].freeze
belongs_to :account
belongs_to :message
From 0722750a553409a7b82c92c840151514c58813ff Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Fri, 22 May 2026 12:16:19 +0530
Subject: [PATCH 04/49] chore: Captain reply actions not showing correctly with
content (#14160)
---
.../widgets/WootWriter/CopilotMenuBar.vue | 22 +++------
.../components/widgets/WootWriter/Editor.vue | 48 ++++++++++---------
.../widgets/WootWriter/ReplyTopPanel.vue | 14 +++++-
.../widgets/conversation/ReplyBox.vue | 16 +++++++
.../dashboard/helper/editorHelper.js | 6 +--
.../helper/specs/editorHelper.spec.js | 27 +++++------
6 files changed, 77 insertions(+), 56 deletions(-)
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue b/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue
index af9cc9f68..524d84ede 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import { useElementSize, useWindowSize } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
-import { useCaptain } from 'dashboard/composables/useCaptain';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
@@ -19,9 +18,11 @@ const props = defineProps({
type: Boolean,
default: false,
},
- editorContent: {
- type: String,
- default: undefined,
+ // Signature-aware emptiness is computed by the parent (which has access to
+ // the signature + channel context) and passed in as a boolean.
+ hasContent: {
+ type: Boolean,
+ default: false,
},
conversationId: {
type: Number,
@@ -33,17 +34,8 @@ const emit = defineEmits(['executeCopilotAction']);
const { t } = useI18n();
-const { draftMessage } = useCaptain();
-
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
-// When editorContent prop is passed, use it exclusively (even if empty)
-// This ensures each editor instance shows menu items based on its own content
-// Falls back to global draftMessage only when editorContent is not provided
-const effectiveContent = computed(() =>
- props.editorContent !== undefined ? props.editorContent : draftMessage.value
-);
-
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
@@ -63,7 +55,7 @@ const menuItems = computed(() => {
} else if (
props.conversationId &&
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
- effectiveContent.value
+ props.hasContent
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
@@ -72,7 +64,7 @@ const menuItems = computed(() => {
});
}
- if (effectiveContent.value) {
+ if (props.hasContent) {
items.push(
{
label: t(
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index d7adb07d0..7881a0d25 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -354,16 +354,17 @@ function isBodyEmpty(content) {
// if content is undefined, we assume that the body is empty
if (!content) return true;
- // if the signature is present, we need to remove it before checking
- // note that we don't update the editorView, so this is safe
- // Use effective channel type to match how signature was appended
- const bodyWithoutSignature = props.signature
- ? removeSignatureHelper(
- content,
- props.signature,
- effectiveChannelType.value
- )
- : content;
+ // Only strip the signature when it's actually being auto-appended for this
+ // draft. Otherwise an agent whose typed text happens to match their saved
+ // signature would be mistakenly treated as empty.
+ const bodyWithoutSignature =
+ sendWithSignature.value && props.signature
+ ? removeSignatureHelper(
+ content,
+ props.signature,
+ effectiveChannelType.value
+ )
+ : content;
// trimming should remove all the whitespaces, so we can check the length
return bodyWithoutSignature.trim().length === 0;
@@ -474,17 +475,6 @@ function removeSignature() {
reloadState(content);
}
-function toggleSignatureInEditor(signatureEnabled) {
- // The toggleSignatureInEditor gets the new value from the
- // watcher, this means that if the value is true, the signature
- // is supposed to be added, else we remove it.
- if (signatureEnabled) {
- addSignature();
- } else {
- removeSignature();
- }
-}
-
function setToolbarPosition() {
const editorRect = editorRoot.value.getBoundingClientRect();
const rect = selectedImageNode.value.getBoundingClientRect();
@@ -559,6 +549,20 @@ function emitOnChange() {
emit('update:modelValue', contentFromEditor());
}
+function toggleSignatureInEditor(signatureEnabled) {
+ // The toggleSignatureInEditor gets the new value from the
+ // watcher, this means that if the value is true, the signature
+ // is supposed to be added, else we remove it.
+ if (signatureEnabled) {
+ addSignature();
+ } else {
+ removeSignature();
+ }
+ // reloadState replaces editor state directly and bypasses dispatchTransaction,
+ // so v-model never hears about the signature change — sync it back explicitly.
+ emitOnChange();
+}
+
function updateImgToolbarOnDelete() {
// check if the selected node is present or not on keyup
// this is needed because the user can select an image and then delete it
@@ -899,7 +903,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected"
:is-editor-menu-popover="isEditorMenuPopover"
- :editor-content="modelValue"
+ :has-content="!isBodyEmpty(modelValue)"
:conversation-id="conversationId"
:show-selection-menu="showSelectionMenu"
:show-general-menu="false"
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue
index cdf577c21..a2929f7f8 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyTopPanel.vue
@@ -53,6 +53,10 @@ export default {
type: String,
default: undefined,
},
+ hasContent: {
+ type: Boolean,
+ default: false,
+ },
},
emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
setup(props, { emit }) {
@@ -76,6 +80,7 @@ export default {
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
+ const copilotToggleRef = ref(null);
const handleCopilotAction = (actionKey, data) => {
emit('executeCopilotAction', actionKey, data || props.editorContent);
@@ -117,6 +122,7 @@ export default {
captainTasksEnabled,
handleCopilotAction,
showCopilotMenu,
+ copilotToggleRef,
toggleCopilotMenu,
handleClickOutside,
};
@@ -164,6 +170,7 @@ export default {
+
diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js
index d0029207e..2aea4d4d5 100644
--- a/app/javascript/dashboard/store/modules/contacts/actions.js
+++ b/app/javascript/dashboard/store/modules/contacts/actions.js
@@ -312,10 +312,14 @@ export const actions = {
commit(types.CLEAR_CONTACT_FILTERS);
},
- initiateCall: async ({ commit }, { contactId, inboxId }) => {
+ initiateCall: async ({ commit }, { contactId, inboxId, conversationId }) => {
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true });
try {
- const response = await ContactAPI.initiateCall(contactId, inboxId);
+ const response = await ContactAPI.initiateCall(
+ contactId,
+ inboxId,
+ conversationId
+ );
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
return response.data;
} catch (error) {
diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js
index da5fc94bf..72ab8fa5e 100644
--- a/app/javascript/dashboard/store/modules/conversations/actions.js
+++ b/app/javascript/dashboard/store/modules/conversations/actions.js
@@ -329,12 +329,21 @@ const actions = {
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
}
- handleVoiceCallCreated(message, rootGetters?.getCurrentUserID);
+ handleVoiceCallCreated(
+ message,
+ rootGetters?.getCurrentUserID,
+ rootGetters?.getCurrentUserAvailability
+ );
},
updateMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
- handleVoiceCallUpdated(commit, message, rootGetters?.getCurrentUserID);
+ handleVoiceCallUpdated(
+ commit,
+ message,
+ rootGetters?.getCurrentUserID,
+ rootGetters?.getCurrentUserAvailability
+ );
},
deleteMessage: async function deleteLabels(
diff --git a/app/javascript/dashboard/stores/calls.js b/app/javascript/dashboard/stores/calls.js
index 4b58b8bb8..2a634a5d9 100644
--- a/app/javascript/dashboard/stores/calls.js
+++ b/app/javascript/dashboard/stores/calls.js
@@ -1,6 +1,16 @@
-import { defineStore } from 'pinia';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
+import { cleanupWhatsappSession } from 'dashboard/composables/useWhatsappCallSession';
+import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
+import { defineStore } from 'pinia';
+
+const teardownByProvider = call => {
+ if (call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP) {
+ cleanupWhatsappSession();
+ } else {
+ TwilioVoiceClient.endClientCall();
+ }
+};
export const useCallsStore = defineStore('calls', {
state: () => ({
@@ -16,15 +26,33 @@ export const useCallsStore = defineStore('calls', {
actions: {
handleCallStatusChanged({ callSid, status }) {
- if (TERMINAL_STATUSES.includes(status)) {
- this.removeCall(callSid);
+ if (!TERMINAL_STATUSES.includes(status)) return;
+
+ const call = this.calls.find(c => c.callSid === callSid);
+ // WhatsApp recordings live in the in-memory recorder until voice_call.ended
+ // uploads them; tearing down here would race-wipe those chunks.
+ if (call?.provider === 'whatsapp') {
+ this.calls = this.calls.filter(c => c.callSid !== callSid);
+ return;
}
+
+ this.removeCall(callSid);
},
addCall(callData) {
if (!callData?.callSid) return;
- const exists = this.calls.some(call => call.callSid === callData.callSid);
- if (exists) return;
+ const existing = this.calls.find(c => c.callSid === callData.callSid);
+ if (existing) {
+ // Merge so a later cable event with sdp_offer/provider/caller fills in
+ // gaps left by the earlier message.created path (and vice versa).
+ // Preserve a previously-captured caller snapshot when the incoming
+ // event has no sender info, otherwise the widget would flip to
+ // "Unknown caller" on the next status update.
+ const next = { ...callData };
+ if (existing.caller && !next.caller) delete next.caller;
+ Object.assign(existing, next, { isActive: existing.isActive });
+ return;
+ }
this.calls.push({
...callData,
@@ -35,7 +63,7 @@ export const useCallsStore = defineStore('calls', {
removeCall(callSid) {
const callToRemove = this.calls.find(c => c.callSid === callSid);
if (callToRemove?.isActive) {
- TwilioVoiceClient.endClientCall();
+ teardownByProvider(callToRemove);
}
this.calls = this.calls.filter(c => c.callSid !== callSid);
},
@@ -48,7 +76,8 @@ export const useCallsStore = defineStore('calls', {
},
clearActiveCall() {
- TwilioVoiceClient.endClientCall();
+ const active = this.calls.find(c => c.isActive);
+ teardownByProvider(active);
this.calls = this.calls.filter(call => !call.isActive);
},
@@ -61,9 +90,10 @@ export const useCallsStore = defineStore('calls', {
call => call.conversationId === conversationId
);
- if (callsToRemove.some(call => call.isActive)) {
- TwilioVoiceClient.endClientCall();
- }
+ // Tear down each active call via its own provider so a WhatsApp call
+ // gets cleanupWhatsappSession() (closes pc, stops recorder/mic) instead
+ // of the Twilio-only endClientCall() — otherwise mic stays open.
+ callsToRemove.filter(call => call.isActive).forEach(teardownByProvider);
this.calls = this.calls.filter(
call => call.conversationId !== conversationId
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index e1d4b226a..2c2205b19 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -44,12 +44,18 @@ class Channel::Whatsapp < ApplicationRecord
# Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow —
# 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs.
def voice_enabled?
- provider == 'whatsapp_cloud' &&
- provider_config['source'] == 'embedded_signup' &&
+ voice_calling_supported? &&
provider_config['calling_enabled'].present? &&
account.feature_enabled?('channel_voice')
end
+ # Whether this inbox can do WhatsApp calling at all. Meta's Calling API is only
+ # reachable via the embedded-signup whatsapp_cloud flow, so manual whatsapp_cloud
+ # and 360dialog inboxes can't be toggled on even though calling_enabled would persist.
+ def voice_calling_supported?
+ provider == 'whatsapp_cloud' && provider_config['source'] == 'embedded_signup'
+ end
+
def provider_service
if provider == 'whatsapp_cloud'
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
@@ -58,6 +64,35 @@ class Channel::Whatsapp < ApplicationRecord
end
end
+ # Enables voice: turns calling on at Meta (idempotent), subscribes the `calls`
+ # webhook field, and sets calling_enabled. Raises on Meta failure.
+ # Saved with validate: false to skip validate_provider_config's remote credential
+ # re-check, which could spuriously fail and desync the flag from Meta.
+ def enable_voice_calling!
+ raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported?
+ raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice')
+
+ provider_service.update_calling_status('ENABLED')
+ webhook_setup_service.register_callback
+ self.provider_config = provider_config.merge('calling_enabled' => true)
+ save!(validate: false)
+ end
+
+ # Disables voice: unsets calling_enabled (gates the call subsystem) and drops
+ # `calls` from the webhook subscription (best-effort, so a Meta outage can't
+ # trap admins). Leaves Meta's WABA calling.status untouched.
+ def disable_voice_calling!
+ raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported?
+
+ self.provider_config = provider_config.merge('calling_enabled' => false)
+ save!(validate: false)
+ begin
+ webhook_setup_service.register_callback(subscribed_fields: %w[messages smb_message_echoes])
+ rescue StandardError => e
+ Rails.logger.warn "[WHATSAPP CALL] disable webhook re-subscribe failed: #{e.message}"
+ end
+ end
+
def mark_message_templates_updated
# rubocop:disable Rails/SkipsModelValidations
update_column(:message_templates_last_updated, Time.zone.now)
@@ -88,10 +123,11 @@ class Channel::Whatsapp < ApplicationRecord
end
def perform_webhook_setup
- business_account_id = provider_config['business_account_id']
- api_key = provider_config['api_key']
+ webhook_setup_service.perform
+ end
- Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
+ def webhook_setup_service
+ Whatsapp::WebhookSetupService.new(self, provider_config['business_account_id'], provider_config['api_key'])
end
def teardown_webhooks
diff --git a/app/policies/inbox_policy.rb b/app/policies/inbox_policy.rb
index d77b183ee..e516a498e 100644
--- a/app/policies/inbox_policy.rb
+++ b/app/policies/inbox_policy.rb
@@ -69,4 +69,12 @@ class InboxPolicy < ApplicationPolicy
def reset_secret?
@account_user.administrator?
end
+
+ def enable_whatsapp_calling?
+ @account_user.administrator?
+ end
+
+ def disable_whatsapp_calling?
+ @account_user.administrator?
+ end
end
diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb
index 94e46dabd..eef84b022 100644
--- a/app/services/whatsapp/facebook_api_client.rb
+++ b/app/services/whatsapp/facebook_api_client.rb
@@ -60,14 +60,16 @@ class Whatsapp::FacebookApiClient
data['code_verification_status'] == 'VERIFIED'
end
- def subscribe_waba_webhook(waba_id, callback_url, verify_token)
+ WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes calls].freeze
+
+ def subscribe_waba_webhook(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
# Step 1: Subscribe app to WABA first (required before override)
# Meta requires the app to be subscribed before using override_callback_uri
# See: https://github.com/chatwoot/chatwoot/issues/13097
subscribe_app_to_waba(waba_id)
# Step 2: Override callback URL for this specific WABA
- override_waba_callback(waba_id, callback_url, verify_token)
+ override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
end
def subscribe_app_to_waba(waba_id)
@@ -79,14 +81,14 @@ class Whatsapp::FacebookApiClient
handle_response(response, 'App subscription to WABA failed')
end
- def override_waba_callback(waba_id, callback_url, verify_token)
+ def override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers,
body: {
override_callback_uri: callback_url,
verify_token: verify_token,
- subscribed_fields: %w[messages smb_message_echoes calls]
+ subscribed_fields: subscribed_fields
}.to_json
)
diff --git a/app/services/whatsapp/webhook_setup_service.rb b/app/services/whatsapp/webhook_setup_service.rb
index 97a53eb9a..a287b4977 100644
--- a/app/services/whatsapp/webhook_setup_service.rb
+++ b/app/services/whatsapp/webhook_setup_service.rb
@@ -17,9 +17,9 @@ class Whatsapp::WebhookSetupService
setup_webhook
end
- def register_callback
+ def register_callback(subscribed_fields: nil)
validate_parameters!
- setup_webhook
+ setup_webhook(subscribed_fields: subscribed_fields)
end
private
@@ -55,12 +55,16 @@ class Whatsapp::WebhookSetupService
@channel.save!
end
- def setup_webhook
+ def setup_webhook(subscribed_fields: nil)
callback_url = build_callback_url
verify_token = @channel.provider_config['webhook_verify_token']
- @api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token)
-
+ args = [@waba_id, callback_url, verify_token]
+ if subscribed_fields
+ @api_client.subscribe_waba_webhook(*args, subscribed_fields: subscribed_fields)
+ else
+ @api_client.subscribe_waba_webhook(*args)
+ end
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
raise "Webhook setup failed: #{e.message}"
diff --git a/config/routes.rb b/config/routes.rb
index 3d2d68269..9f89466ff 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -261,6 +261,8 @@ Rails.application.routes.draw do
resource :conference, only: %i[create destroy], controller: 'conference' do
get :token, on: :member
end
+ post :enable_whatsapp_calling, on: :member
+ post :disable_whatsapp_calling, on: :member
end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do
diff --git a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
index 1123699d8..0bea29843 100644
--- a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
@@ -27,7 +27,10 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
def destroy
call = resolve_call!
+ rejecting = agent_rejecting_before_pickup?(call)
+ # Tear down provider side first so a teardown failure leaves the call repairable.
Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
+ finalize_as_agent_reject!(call) if rejecting
render json: { status: 'success', id: call.conversation.display_id }
end
@@ -59,4 +62,21 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
def render_call_already_accepted(error)
render json: { error: error.message }, status: :conflict
end
+
+ # A hangup before pickup is treated as an agent rejection, matching WhatsApp.
+ def agent_rejecting_before_pickup?(call)
+ call.ringing? && call.accepted_by_agent_id.nil?
+ end
+
+ def finalize_as_agent_reject!(call)
+ # Re-check under a row lock: a webhook may have accepted/completed the call
+ # while end_conference was in flight, so don't force agent_rejected on stale state.
+ rejected = call.with_lock do
+ next false unless agent_rejecting_before_pickup?(call)
+
+ call.update!(status: 'failed', end_reason: 'agent_rejected', accepted_by_agent_id: Current.user.id)
+ true
+ end
+ Voice::CallMessageBuilder.new(call).update_status!(status: 'failed', agent: Current.user) if rejected
+ end
end
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
index f9d828806..76f578bf1 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
@@ -3,12 +3,38 @@ module Enterprise::Api::V1::Accounts::InboxesController
super + ee_inbox_attributes
end
+ def enable_whatsapp_calling
+ return unless ensure_whatsapp_calling_supported
+
+ @inbox.channel.enable_voice_calling!
+ head :ok
+ rescue StandardError => e
+ render_could_not_create_error(e.message)
+ end
+
+ def disable_whatsapp_calling
+ return unless ensure_whatsapp_calling_supported
+
+ @inbox.channel.disable_voice_calling!
+ head :ok
+ rescue StandardError => e
+ render_could_not_create_error(e.message)
+ end
+
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
private
+ def ensure_whatsapp_calling_supported
+ channel = @inbox.channel
+ return true if channel.is_a?(Channel::Whatsapp) && channel.voice_calling_supported?
+
+ render_could_not_create_error('Inbox does not support WhatsApp calling')
+ false
+ end
+
def allowed_channel_types
super + ['voice']
end
diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb
index f8c0580f8..e111cdd48 100644
--- a/enterprise/app/models/call.rb
+++ b/enterprise/app/models/call.rb
@@ -116,6 +116,7 @@ class Call < ApplicationRecord
direction: direction,
status: display_status,
duration_seconds: duration_seconds,
+ end_reason: end_reason,
conference_sid: conference_sid,
accepted_by_agent_id: accepted_by_agent_id,
accepted_by_agent_name: accepted_by_agent&.available_name,
diff --git a/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb b/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
index ec29a5a38..2fcf4b5e7 100644
--- a/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
+++ b/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
@@ -40,6 +40,22 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
process_initiate_call_response(response)
end
+ # Sets WABA calling status ('ENABLED'/'DISABLED'). Returns true, or raises with
+ # Meta's user-facing message on failure so the caller can surface it.
+ def update_calling_status(status)
+ response = HTTParty.post(
+ "#{calls_phone_id_path}/settings",
+ headers: api_headers,
+ body: { calling: { status: status } }.to_json
+ )
+ return true if response.success?
+
+ parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {}
+ message = parsed.dig('error', 'error_user_msg') || parsed.dig('error', 'message') || 'Failed to update calling status'
+ Rails.logger.error "[WHATSAPP CALL] update_calling_status failed: status=#{response.code} body=#{response.body}"
+ raise message
+ end
+
private
def calls_phone_id_path
diff --git a/enterprise/app/services/voice/call_status/manager.rb b/enterprise/app/services/voice/call_status/manager.rb
index 73ace3a78..942ee0cc0 100644
--- a/enterprise/app/services/voice/call_status/manager.rb
+++ b/enterprise/app/services/voice/call_status/manager.rb
@@ -4,6 +4,9 @@ class Voice::CallStatus::Manager
def process_status_update(status, duration: nil, timestamp: nil)
return unless Call::STATUSES.include?(status)
return if call.status == status
+ # Don't overwrite a terminal status — Twilio's late `completed` events would
+ # otherwise clobber an agent-rejection reason.
+ return if Call::TERMINAL_STATUSES.include?(call.status)
apply_call_updates!(status, duration: duration, timestamp: timestamp)
call.conversation.update!(last_activity_at: Time.zone.now)
diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb
index 7b8b0e684..b6fb089b7 100644
--- a/enterprise/app/services/voice/inbound_call_builder.rb
+++ b/enterprise/app/services/voice/inbound_call_builder.rb
@@ -74,15 +74,14 @@ class Voice::InboundCallBuilder
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(digits, :cloud)
end
+ # Mirror incoming-message routing: reuse the open conversation (or the last one when locked), else create new.
def resolve_conversation!(contact, contact_inbox)
- if inbox.lock_to_single_conversation
- reusable = account.conversations
- .where(contact_id: contact.id, inbox_id: inbox.id)
- .where.not(status: :resolved)
- .order(last_activity_at: :desc)
- .first
- return reusable if reusable
- end
+ reusable = if inbox.lock_to_single_conversation
+ contact_inbox.conversations.last
+ else
+ contact_inbox.conversations.where.not(status: :resolved).last
+ end
+ return reusable if reusable
account.conversations.create!(
contact_inbox_id: contact_inbox.id,
diff --git a/enterprise/app/services/whatsapp/call_service.rb b/enterprise/app/services/whatsapp/call_service.rb
index 8a52ea6bc..93eba957c 100644
--- a/enterprise/app/services/whatsapp/call_service.rb
+++ b/enterprise/app/services/whatsapp/call_service.rb
@@ -20,7 +20,8 @@ class Whatsapp::CallService
next if call.terminal? || call.in_progress?
invoke_provider!(:reject_call)
- finalize_call('failed')
+ call.update!(accepted_by_agent_id: agent.id) if call.accepted_by_agent_id.nil?
+ finalize_call('failed', end_reason: 'agent_rejected')
end
call
end
diff --git a/enterprise/app/services/whatsapp/incoming_call_service.rb b/enterprise/app/services/whatsapp/incoming_call_service.rb
index d290e96c2..99f6350ff 100644
--- a/enterprise/app/services/whatsapp/incoming_call_service.rb
+++ b/enterprise/app/services/whatsapp/incoming_call_service.rb
@@ -159,17 +159,22 @@ class Whatsapp::IncomingCallService
)
end
- # Ring the assignee if assigned; otherwise account-wide so any agent can pick up.
+ # Ring the assignee if any, else online inbox agents, else the whole account.
def broadcast_incoming(call, sdp_offer)
contact = call.contact
token = call.conversation.assignee&.pubsub_token
+ streams = token ? [token] : (online_agent_streams.presence || account_streams)
broadcast(call, 'voice_call.incoming',
- streams: token ? [token] : account_streams,
+ streams: streams,
direction: call.direction_label, inbox_id: call.inbox_id,
sdp_offer: sdp_offer, ice_servers: Call.default_ice_servers,
caller: { name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url })
end
+ def online_agent_streams
+ inbox.available_agents.pluck('users.pubsub_token').compact
+ end
+
def broadcast(call, event, streams: account_streams, **extra)
payload = { event: event, data: base_payload(call).merge(extra) }
streams.each { |s| ActionCable.server.broadcast(s, payload) }
From 03fb6591e07f8de244ba80292d5428a271dc2720 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Mon, 25 May 2026 14:21:14 +0530
Subject: [PATCH 12/49] chore: relax conversation meta polling for high-volume
accounts (#14518)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On high-volume accounts, the dashboard sidebar's conversation count
badges fall behind because a meaningful share of
`/api/v1/accounts/:id/conversations/meta` requests get rate-limited
(per-user throttle, default 30 req/min).
Root cause is in `conversationStats.js`. The tiered debounce uses
`allCount` from the last response to pick a wait interval. `allCount`
reflects the user's *current filtered scope*, not the account's true
volume — so an agent viewing a small filter on a busy account falls into
the most aggressive tier (500ms wait / 1.5s maxWait → up to 40
calls/min/tab) and trips the throttle.
## What changed
`app/javascript/dashboard/store/modules/conversationStats.js`:
- Short-tier `maxWait`: `1500 → 2000` (caps short-tier at 30/min/tab
instead of 40)
- Super-long-tier threshold: `allCount > 5000 → > 2000` (more
high-volume accounts fall into the safe 3/min/tab tier)
- Middle-tier threshold unchanged (`> 100`)
| Tier (allCount) | wait / maxWait | Calls/min/tab |
|---|---|---|
| `> 2000` | 10s / 20s | 3 |
| `> 100` | 5s / 10s | 6 |
| else | 500ms / 2s | 30 |
## Trade-off
Badge updates (including those triggered by the agent's own action) may
lag by up to the tier's `maxWait` — worst case 20s for accounts with >
2000 open conversations in the active scope. The conversation list
itself and push notifications continue to update in real time; only the
numeric badge is debounced.
## Not in scope
- Sticky-max `allCount` to fix the underlying tier-selection signal —
defer until the simpler tuning is validated in production
- Optimistic count updates on local user actions — adds non-trivial
state management for a cosmetic lag
---
app/javascript/dashboard/store/modules/conversationStats.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/javascript/dashboard/store/modules/conversationStats.js b/app/javascript/dashboard/store/modules/conversationStats.js
index ba3e5c455..204b87ea1 100644
--- a/app/javascript/dashboard/store/modules/conversationStats.js
+++ b/app/javascript/dashboard/store/modules/conversationStats.js
@@ -25,7 +25,7 @@ const fetchMetaData = async (commit, params) => {
}
};
-const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1500);
+const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 2000);
const longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000);
const superLongDebouncedFetchMetaData = debounce(
fetchMetaData,
@@ -36,7 +36,7 @@ const superLongDebouncedFetchMetaData = debounce(
export const actions = {
get: async ({ commit, state: $state }, params) => {
- if ($state.allCount > 5000) {
+ if ($state.allCount > 2000) {
superLongDebouncedFetchMetaData(commit, params);
} else if ($state.allCount > 100) {
longDebouncedFetchMetaData(commit, params);
From 52da165cb7d48e68591f87f25c75dc08a9371262 Mon Sep 17 00:00:00 2001
From: Vishnu Narayanan
Date: Mon, 25 May 2026 15:16:52 +0530
Subject: [PATCH 13/49] feat: add timeout for imap email job and skip
problematic emails (#11981)
# Pull Request Template
## Description
Large emails (2MB+ with multiple attachments) were causing IMAP email
processing jobs to timeout silently, blocking all subsequent emails from
being processed. This created an infinite loop where:
- Problematic emails were repeatedly fetched but never successfully
processed
- Other emails in the queue were never processed as we iterated
sequentially
- silent failures
### Solution
Enhanced the FetchImapEmailsJob with individual email processing
isolation:
### Key Changes
1. Individual Email Processing: Changed from map to each for better
memory efficiency
2. Timeout Protection: Added configurable timeout per email (default: 60
seconds)
3. Failure Tracking: Track failed emails with 6-hour expiry for retry
opportunities
4. Skip Logic: Skip emails that have failed 3+ times to prevent infinite
loops
5. Error Isolation: Each email is processed in its own error boundary
### Configuration
- Timeout: Configurable via EMAIL_PROCESSING_TIMEOUT_SECONDS using
GlobalConfigService
- Default: 60 seconds per email
- Failure Limit: 3 attempts before skipping
- Retry Window: 6 hours so that emails get 8 more chances in the 2 day
window
### Benefits
- Prevents queue blocking: One problematic email cannot stop others
- Maintains email order: Older emails (customers waiting longer)
processed first
- Automatic recovery: Failed emails get retry opportunities
- Better monitoring: Clear logging when emails timeout or are skipped
- Configurable: Deployments can adjust the timeout based on their needs
This fix ensures email processing reliability while maintaining existing
functionality.
## Type of change
Please delete options that are not relevant.
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
## Checklist:
- [ ] 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
---
.env.example | 2 +
app/jobs/inboxes/fetch_imap_emails_job.rb | 40 ++++++++++++++++---
.../inboxes/fetch_imap_emails_job_spec.rb | 40 ++++++++++++++++++-
3 files changed, 75 insertions(+), 7 deletions(-)
diff --git a/.env.example b/.env.example
index bc7380a29..8a4f0bb5d 100644
--- a/.env.example
+++ b/.env.example
@@ -98,6 +98,8 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN=
+# Maximum time in seconds to process a single IMAP email
+# EMAIL_PROCESSING_TIMEOUT_SECONDS=60
# Set this to the appropriate ingress channel with regards to incoming emails
# Possible values are :
# relay for Exim, Postfix, Qmail
diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb
index e98edf409..e2c48488b 100644
--- a/app/jobs/inboxes/fetch_imap_emails_job.rb
+++ b/app/jobs/inboxes/fetch_imap_emails_job.rb
@@ -36,7 +36,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
else
Imap::FetchEmailService.new(channel: channel, interval: interval).perform
end
- inbound_emails.map do |inbound_mail|
+
+ inbound_emails.each do |inbound_mail|
process_mail(inbound_mail, channel)
end
rescue OAuth2::Error => e
@@ -44,11 +45,38 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
channel.authorization_error!
end
+ def should_skip_email?(message_id)
+ failure_count = Rails.cache.read("email_failures:#{message_id}") || 0
+ failure_count >= 3
+ end
+
+ def mark_email_as_failed(message_id)
+ failure_count = Rails.cache.read("email_failures:#{message_id}") || 0
+ Rails.cache.write("email_failures:#{message_id}", failure_count + 1, expires_in: 6.hours)
+ end
+
def process_mail(inbound_mail, channel)
- Imap::ImapMailbox.new.process(inbound_mail, channel)
- rescue StandardError => e
- ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
- Rails.logger.error("
- #{channel.provider} Email dropped: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}")
+ # Skip if this email has failed multiple times recently
+ if should_skip_email?(inbound_mail.message_id)
+ Rails.logger.warn "[IMAP] Skipping problematic email: #{inbound_mail.message_id}"
+ return
+ end
+
+ begin
+ Timeout.timeout(email_processing_timeout) do
+ Imap::ImapMailbox.new.process(inbound_mail, channel)
+ end
+ rescue Timeout::Error
+ mark_email_as_failed(inbound_mail.message_id)
+ Rails.logger.error "[IMAP] Email processing timeout (#{email_processing_timeout}s): #{inbound_mail.message_id}"
+ rescue StandardError => e
+ mark_email_as_failed(inbound_mail.message_id)
+ Rails.logger.error "[IMAP] Failed to process email #{inbound_mail.message_id}: #{e.message}"
+ ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
+ end
+ end
+
+ def email_processing_timeout
+ GlobalConfigService.load('EMAIL_PROCESSING_TIMEOUT_SECONDS', 60).to_i
end
end
diff --git a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb
index da4b95b15..04336c619 100644
--- a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb
+++ b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb
@@ -88,7 +88,10 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
end
context 'when the fetch service returns the email objects' do
- let(:inbound_mail) { create_inbound_email_from_fixture('welcome.eml').mail }
+ let(:inbound_mail) { instance_double(Mail::Message, message_id: 'message-id') }
+ let(:failure_cache_key) { "email_failures:#{inbound_mail.message_id}" }
+ let(:second_inbound_mail) { instance_double(Mail::Message, message_id: 'second-message-id') }
+ let(:second_failure_cache_key) { "email_failures:#{second_inbound_mail.message_id}" }
let(:mailbox) { double }
let(:exception_tracker) { double }
let(:fetch_service) { double }
@@ -101,6 +104,11 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
allow(fetch_service).to receive(:perform).and_return([inbound_mail])
end
+ after do
+ Rails.cache.delete(failure_cache_key)
+ Rails.cache.delete(second_failure_cache_key)
+ end
+
it 'calls the mailbox to create emails' do
allow(mailbox).to receive(:process)
@@ -111,6 +119,36 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
described_class.perform_now(imap_email_channel)
end
+ it 'marks the email as failed when processing times out' do
+ allow(Timeout).to receive(:timeout).and_raise(Timeout::Error)
+ allow(Rails.cache).to receive(:read).and_call_original
+ allow(Rails.cache).to receive(:read).with(failure_cache_key).and_return(nil)
+
+ expect(Rails.cache).to receive(:write).with(failure_cache_key, 1, expires_in: 6.hours)
+
+ described_class.perform_now(imap_email_channel)
+ end
+
+ it 'continues processing remaining emails when one email fails' do
+ allow(fetch_service).to receive(:perform).and_return([inbound_mail, second_inbound_mail])
+ allow(mailbox).to receive(:process).with(inbound_mail, imap_email_channel).and_raise(StandardError)
+ allow(mailbox).to receive(:process).with(second_inbound_mail, imap_email_channel)
+ allow(exception_tracker).to receive(:capture_exception)
+
+ described_class.perform_now(imap_email_channel)
+
+ expect(mailbox).to have_received(:process).with(second_inbound_mail, imap_email_channel)
+ end
+
+ it 'skips emails that have failed multiple times recently' do
+ allow(Rails.cache).to receive(:read).and_call_original
+ allow(Rails.cache).to receive(:read).with(failure_cache_key).and_return(3)
+
+ expect(mailbox).not_to receive(:process)
+
+ described_class.perform_now(imap_email_channel)
+ end
+
it 'logs errors if mailbox returns errors' do
allow(mailbox).to receive(:process).and_raise(StandardError)
From 6fbff026eb1ad7727db48ba9ebdb18a61cd4c571 Mon Sep 17 00:00:00 2001
From: Vishnu Narayanan
Date: Mon, 25 May 2026 15:17:05 +0530
Subject: [PATCH 14/49] fix: skip AutoAssignment bulk loop when no agents are
online (#14500)
## Description
When an inbox has `enable_auto_assignment` and `assignment_v2` enabled
but no agents are currently online,
`AutoAssignment::AssignmentService#perform_bulk_assignment` still loaded
up to 100 unassigned conversations and iterated each one, calling
`inbox.available_agents` per conversation. Each call hits Redis presence
lookups that return empty, no conversations get assigned, and the loop
finishes having done only wasted work.
For a busy inbox with a long unassigned backlog and offline agents, this
is hundreds of Redis ops per job, multiplied by every
`AutoAssignment::AssignmentJob` enqueue from the per-save handler. The
pressure is significant when inbound volume is high.
This adds a single early-return guard: if
`inbox.available_agents.empty?`, return `0` immediately. Existing
semantics are preserved (jobs are still enqueued on conversation events;
they just exit cheaply when there is no one to assign to).
## Type of change
- [x] Performance improvement (non-breaking change)
## Test coverage
- [x] Added specs
---
app/services/auto_assignment/assignment_service.rb | 8 +++++---
.../auto_assignment/assignment_service_spec.rb | 13 +++++++++++++
2 files changed, 18 insertions(+), 3 deletions(-)
diff --git a/app/services/auto_assignment/assignment_service.rb b/app/services/auto_assignment/assignment_service.rb
index f2d2799ff..0ad8a9004 100644
--- a/app/services/auto_assignment/assignment_service.rb
+++ b/app/services/auto_assignment/assignment_service.rb
@@ -5,12 +5,14 @@ class AutoAssignment::AssignmentService
return 0 unless inbox.auto_assignment_v2_enabled?
return 0 unless inbox.enable_auto_assignment?
- assigned_count = 0
+ conversations = unassigned_conversations(limit).to_a
+ return 0 if conversations.empty?
+ return 0 if inbox.available_agents.empty?
- unassigned_conversations(limit).each do |conversation|
+ assigned_count = 0
+ conversations.each do |conversation|
assigned_count += 1 if perform_for_conversation(conversation)
end
-
assigned_count
end
diff --git a/spec/services/auto_assignment/assignment_service_spec.rb b/spec/services/auto_assignment/assignment_service_spec.rb
index 36a8c7816..eb6ebf060 100644
--- a/spec/services/auto_assignment/assignment_service_spec.rb
+++ b/spec/services/auto_assignment/assignment_service_spec.rb
@@ -56,6 +56,19 @@ RSpec.describe AutoAssignment::AssignmentService do
expect(conversation.reload.assignee).to be_nil
end
+ it 'short-circuits without iterating conversations when no agents are online' do
+ 3.times do
+ conv = create(:conversation, inbox: inbox, status: 'open')
+ conv.update!(assignee_id: nil)
+ end
+ allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
+
+ expect(service).not_to receive(:perform_for_conversation)
+
+ assigned_count = service.perform_bulk_assignment(limit: 10)
+ expect(assigned_count).to eq(0)
+ end
+
it 'respects the limit parameter' do
3.times do
conv = create(:conversation, inbox: inbox, status: 'open')
From 56e30102ebc3b97e4eb576868fdec5eab2d13886 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Mon, 25 May 2026 16:43:59 +0400
Subject: [PATCH 15/49] fix(whatsapp): store and surface unavailable
coexistence messages (CW-7166) (#14547)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
In WhatsApp coexistence setups (Business App + Cloud API on the same
number), some inbound customer messages arrive from Meta as `type:
unsupported` with error `131060` ("This message is unavailable") and no
content — typically the first message of a Click-to-WhatsApp /
Instagram-ad conversation, or a message synced from a companion device.
Chatwoot was dropping these webhooks entirely, so no contact,
conversation, or message was created. The conversation only surfaced
once an agent replied (via an `smb_message_echoes` event), starting
"headless" with zero customer context.
This change persists a placeholder message for these events so the
contact and conversation are created, and renders it with the dedicated
unsupported-message bubble that points agents to the WhatsApp app —
where the original message is still visible.
Fixes
https://linear.app/chatwoot/issue/CW-7166/whatsapp-coexistence-inbound-messages-are-silently-dropped
and https://github.com/chatwoot/chatwoot/issues/13464
## How to reproduce
1. Connect a WhatsApp Cloud (coexistence) inbox.
2. Receive an inbound message that Meta delivers as `type: unsupported`
with error `131060` (e.g. a Click-to-WhatsApp ad message, or a message
handled on a companion/primary device that fails to sync to the API).
3. **Before:** nothing is created — the conversation only appears after
an agent replies, with no record of the customer's first message.
4. **After:** the contact and conversation are created with an incoming
placeholder message rendered as the amber "unsupported" bubble: _"This
message is unsupported. You can view this message on the WhatsApp app."
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context)
Co-authored-by: Sojan Jose
---
.../message/bubbles/Unsupported.vue | 11 ++++++++---
.../dashboard/i18n/locale/en/conversation.json | 1 +
.../whatsapp/incoming_message_base_service.rb | 14 ++++++++++++++
.../whatsapp/incoming_message_service_helpers.rb | 2 +-
config/locales/en.yml | 1 +
.../whatsapp/incoming_message_service_spec.rb | 11 +++++++----
6 files changed, 32 insertions(+), 8 deletions(-)
diff --git a/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue b/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue
index be67f85ca..5f6544738 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue
@@ -6,9 +6,12 @@ import BaseBubble from './Base.vue';
const { inboxId } = useMessageContext();
-const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
- inboxId.value
-);
+const {
+ isAFacebookInbox,
+ isAnInstagramChannel,
+ isATiktokChannel,
+ isAWhatsAppChannel,
+} = useInbox(inboxId.value);
const unsupportedMessageKey = computed(() => {
if (isAFacebookInbox.value)
@@ -16,6 +19,8 @@ const unsupportedMessageKey = computed(() => {
if (isAnInstagramChannel.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
+ if (isAWhatsAppChannel.value)
+ return 'CONVERSATION.UNSUPPORTED_MESSAGE_WHATSAPP';
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
});
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index c7017608d..f0ff6811a 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -62,6 +62,7 @@
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
+ "UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response",
diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb
index 82aa7ab18..722ac3e4d 100644
--- a/app/services/whatsapp/incoming_message_base_service.rb
+++ b/app/services/whatsapp/incoming_message_base_service.rb
@@ -67,6 +67,8 @@ class Whatsapp::IncomingMessageBaseService
def create_messages
message = messages_data.first
+ return create_unsupported_message(message) if message_type == 'unsupported'
+
log_error(message) && return if error_webhook_event?(message)
process_in_reply_to(message)
@@ -74,6 +76,18 @@ class Whatsapp::IncomingMessageBaseService
message_type == 'contacts' ? create_contact_messages(message) : create_regular_message(message)
end
+ # WhatsApp delivers messages it cannot render (e.g. coexistence companion-device syncs that
+ # fail with error 131060) as type: unsupported with no content. We still persist a placeholder
+ # so the contact/conversation isn't created "headless" and agents know to check the WhatsApp app.
+ def create_unsupported_message(message)
+ log_error(message) if error_webhook_event?(message)
+ process_in_reply_to(message)
+ create_message(message, source_id: message[:id])
+ @message.content = I18n.t('conversations.messages.whatsapp.unsupported_message')
+ @message.content_attributes = @message.content_attributes.merge(is_unsupported: true)
+ @message.save!
+ end
+
def create_contact_messages(message)
message['contacts'].each do |contact|
# Pass source_id from parent message since contact objects don't have :id
diff --git a/app/services/whatsapp/incoming_message_service_helpers.rb b/app/services/whatsapp/incoming_message_service_helpers.rb
index 8ec884268..27a854479 100644
--- a/app/services/whatsapp/incoming_message_service_helpers.rb
+++ b/app/services/whatsapp/incoming_message_service_helpers.rb
@@ -44,7 +44,7 @@ module Whatsapp::IncomingMessageServiceHelpers
end
def unprocessable_message_type?(message_type)
- %w[reaction ephemeral unsupported request_welcome].include?(message_type)
+ %w[reaction ephemeral request_welcome].include?(message_type)
end
def processed_waid(waid)
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 18b721caf..b11a4fdae 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -258,6 +258,7 @@ en:
whatsapp:
list_button_label: 'Choose an item'
call_permission_request_body: 'We would like to call you regarding your conversation.'
+ unsupported_message: 'This message is unavailable.'
voice_call:
twilio: 'Voice Call'
whatsapp: 'WhatsApp Call'
diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb
index dbaea621c..fe6b179c1 100644
--- a/spec/services/whatsapp/incoming_message_service_spec.rb
+++ b/spec/services/whatsapp/incoming_message_service_spec.rb
@@ -206,7 +206,7 @@ describe Whatsapp::IncomingMessageService do
expect(whatsapp_channel.inbox.messages.count).to eq(0)
end
- it 'ignores type unsupported and does not create ghost conversation' do
+ it 'stores type unsupported as a placeholder message so the conversation is not headless' do
params = {
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
'messages' => [{
@@ -217,9 +217,12 @@ describe Whatsapp::IncomingMessageService do
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
- expect(whatsapp_channel.inbox.conversations.count).to eq(0)
- expect(Contact.count).to eq(0)
- expect(whatsapp_channel.inbox.messages.count).to eq(0)
+ expect(whatsapp_channel.inbox.conversations.count).to eq(1)
+ expect(Contact.count).to eq(1)
+ expect(whatsapp_channel.inbox.messages.count).to eq(1)
+ message = whatsapp_channel.inbox.messages.last
+ expect(message.content).to eq('This message is unavailable.')
+ expect(message.content_attributes['is_unsupported']).to be(true)
end
end
From 75c2f910191f5a9ce49eb1d72d4d3bec9d78fd85 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Mon, 25 May 2026 17:12:55 +0400
Subject: [PATCH 16/49] chore: Enable Tiktok on paid plans Automatically
(#13628)
This PR add the ability enable Tiktok integration on all paid plans.
---
.../enterprise/billing/reconcile_plan_features_service.rb | 1 +
1 file changed, 1 insertion(+)
diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
index 932cee661..d543adad0 100644
--- a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
+++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
@@ -11,6 +11,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
channel_facebook
channel_email
channel_instagram
+ channel_tiktok
captain_integration
advanced_search_indexing
advanced_search
From 37c8e7e6997f02e3dc76af4c92477bfe83b3fea6 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Tue, 26 May 2026 14:07:07 +0530
Subject: [PATCH 17/49] fix: firecrawl long external link (#14566)
# Pull Request Template
## Description
Fixes urls going past 255 chars, this is because of arabic urls, where
each character balloons to 8-9 characters and goes past the 255 limit
## Type of change
Please delete options that are not relevant.
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
specs
## 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
---
...nge_captain_document_external_link_to_text.rb | 16 ++++++++++++++++
db/schema.rb | 6 +++---
enterprise/app/models/captain/document.rb | 14 +++++++-------
.../captain/tools/firecrawl_parser_job_spec.rb | 10 ++++++++++
4 files changed, 36 insertions(+), 10 deletions(-)
create mode 100644 db/migrate/20260525093000_change_captain_document_external_link_to_text.rb
diff --git a/db/migrate/20260525093000_change_captain_document_external_link_to_text.rb b/db/migrate/20260525093000_change_captain_document_external_link_to_text.rb
new file mode 100644
index 000000000..50cee2b0d
--- /dev/null
+++ b/db/migrate/20260525093000_change_captain_document_external_link_to_text.rb
@@ -0,0 +1,16 @@
+class ChangeCaptainDocumentExternalLinkToText < ActiveRecord::Migration[7.0]
+ OLD_INDEX_NAME = 'index_captain_documents_on_assistant_id_and_external_link'.freeze
+ NEW_INDEX_NAME = 'idx_captain_documents_on_assistant_id_and_external_link_md5'.freeze
+
+ def up
+ remove_index :captain_documents, name: OLD_INDEX_NAME, if_exists: true
+ change_column :captain_documents, :external_link, :text, null: false
+ add_index :captain_documents, 'assistant_id, md5(external_link)', unique: true, name: NEW_INDEX_NAME, if_not_exists: true
+ end
+
+ def down
+ remove_index :captain_documents, name: NEW_INDEX_NAME, if_exists: true
+ change_column :captain_documents, :external_link, :string, null: false
+ add_index :captain_documents, [:assistant_id, :external_link], unique: true, name: OLD_INDEX_NAME, if_not_exists: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 9d9fe3cbc..f2c479571 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_05_15_000000) do
+ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -370,7 +370,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
create_table "captain_documents", force: :cascade do |t|
t.string "name"
- t.string "external_link", null: false
+ t.text "external_link", null: false
t.text "content"
t.bigint "assistant_id", null: false
t.bigint "account_id", null: false
@@ -381,10 +381,10 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.integer "sync_status"
t.datetime "last_synced_at"
t.datetime "last_sync_attempted_at"
+ t.index "assistant_id, md5(external_link)", name: "idx_captain_documents_on_assistant_id_and_external_link_md5", unique: true
t.index ["account_id", "assistant_id", "sync_status", "last_synced_at"], name: "idx_captain_documents_on_account_assistant_sync_stats"
t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
t.index ["account_id"], name: "index_captain_documents_on_account_id"
- t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
t.index ["status"], name: "index_captain_documents_on_status"
end
diff --git a/enterprise/app/models/captain/document.rb b/enterprise/app/models/captain/document.rb
index 0fe14813b..6eda0eb5a 100644
--- a/enterprise/app/models/captain/document.rb
+++ b/enterprise/app/models/captain/document.rb
@@ -5,7 +5,7 @@
# id :bigint not null, primary key
# content :text
# content_fingerprint :string
-# external_link :string not null
+# external_link :text not null
# last_sync_attempted_at :datetime
# last_sync_error_code :string
# last_synced_at :datetime
@@ -20,12 +20,12 @@
#
# Indexes
#
-# idx_captain_documents_on_account_assistant_sync_stats (account_id,assistant_id,sync_status,last_synced_at)
-# index_captain_documents_on_account_id (account_id)
-# index_captain_documents_on_account_id_and_sync_status (account_id,sync_status)
-# index_captain_documents_on_assistant_id (assistant_id)
-# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
-# index_captain_documents_on_status (status)
+# idx_captain_documents_on_account_assistant_sync_stats (account_id,assistant_id,sync_status,last_synced_at)
+# idx_captain_documents_on_assistant_id_and_external_link_md5 (assistant_id, md5(external_link)) UNIQUE
+# index_captain_documents_on_account_id (account_id)
+# index_captain_documents_on_account_id_and_sync_status (account_id,sync_status)
+# index_captain_documents_on_assistant_id (assistant_id)
+# index_captain_documents_on_status (status)
#
class Captain::Document < ApplicationRecord
class LimitExceededError < StandardError; end
diff --git a/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb b/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb
index e12efc54b..6aed60385 100644
--- a/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb
+++ b/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb
@@ -61,6 +61,16 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
end
end
+ it 'stores external links longer than 255 characters' do
+ long_url = "https://example.com/#{'arabic-product-slug-' * 300}"
+ payload[:metadata]['url'] = long_url
+
+ described_class.perform_now(assistant_id: assistant.id, payload: payload)
+
+ expect(assistant.documents.last.external_link).to eq(long_url)
+ expect(assistant.documents.last.external_link.length).to be > 255
+ end
+
context 'when an error occurs' do
it 'raises an error with a descriptive message' do
allow(Captain::Assistant).to receive(:find).and_raise(ActiveRecord::RecordNotFound)
From b981ba766f2416f5fc30762b2270f5655cf50924 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Tue, 26 May 2026 15:23:51 +0530
Subject: [PATCH 18/49] feat: support bulk label removal (#14534)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds bulk label removal alongside the existing assign-label action for
conversations and contacts, so teams can clean up labels across selected
records without opening each item individually.
For conversations, the remove dropdown is scoped to labels that are
actually applied across the current selection — so agents no longer see
(or accidentally "remove") labels that aren't on any of the selected
items. For contacts, the dropdown still lists all account labels for
now; label data isn't carried on the contact list payload today, so
scoping the contact remove menu cleanly is being tracked as a follow-up.
## Closes
N/A
## How to test
- Open the conversation list, select multiple conversations, open
**Remove labels**, and confirm the dropdown only lists labels that are
applied to at least one selected conversation. Pick a label and confirm
it's removed from the selection.
- Open Contacts, select multiple contacts, use **Remove Labels**, choose
a label, and confirm the selected contacts are refreshed without that
label.
- Verify **Assign Labels** still works for conversations and contacts,
and continues to show every available label.
## What changed
- Adds an `action` prop to the shared `BulkLabelActions` dropdown so it
can render in `assign` or `remove` mode.
- Wires conversation bulk remove to the existing `labels.remove` backend
path and filters the dropdown to the union of labels applied across the
selected conversations.
- Adds contact bulk remove support through
`Contacts::BulkRemoveLabelsService`, routed by
`Contacts::BulkActionService`.
- Raises contact label save failures instead of reporting a successful
bulk action when a contact update is invalid.
## Follow-ups
- Scope the contact remove dropdown to applied labels (needs a
lightweight endpoint, or eventually `cached_label_list` on `Contact`).
## Verification
Conversation bulk remove selector:
Contact bulk remove selector:
Video proof:
https://github.com/user-attachments/assets/fffafe19-4e1c-4e2a-a135-c7182c06bb4d
---------
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin
---
.../BulkLabelActions.vue | 59 +++++++++++++++----
.../conversationBulkActions/Index.vue | 19 ++++++
.../composables/chatlist/useBulkActions.js | 26 +++++---
.../dashboard/i18n/locale/en/bulkActions.json | 6 +-
.../dashboard/i18n/locale/en/contact.json | 3 +
.../components/ContactsBulkActionBar.vue | 12 ++++
.../contacts/pages/ContactsIndex.vue | 23 ++++++++
app/services/contacts/bulk_action_service.rb | 13 ++++
.../contacts/bulk_remove_labels_service.rb | 19 ++++++
.../accounts/bulk_actions_controller_spec.rb | 25 ++++++++
.../contacts/bulk_action_service_spec.rb | 14 +++++
.../bulk_remove_labels_service_spec.rb | 54 +++++++++++++++++
theme/icons.js | 5 ++
13 files changed, 259 insertions(+), 19 deletions(-)
create mode 100644 app/services/contacts/bulk_remove_labels_service.rb
create mode 100644 spec/services/contacts/bulk_remove_labels_service_spec.rb
diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue
index bbbf30090..e46f45da5 100644
--- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue
@@ -14,6 +14,11 @@ const props = defineProps({
type: String,
default: 'conversation',
},
+ action: {
+ type: String,
+ default: 'assign',
+ validator: value => ['assign', 'remove'].includes(value),
+ },
isLoading: {
type: Boolean,
default: false,
@@ -22,9 +27,13 @@ const props = defineProps({
type: Boolean,
default: false,
},
+ appliedLabels: {
+ type: Array,
+ default: null,
+ },
});
-const emit = defineEmits(['assign']);
+const emit = defineEmits(['assign', 'remove']);
const { t } = useI18n();
@@ -35,17 +44,43 @@ const [showDropdown, toggleDropdown] = useToggle(false);
const selectedLabels = ref([]);
const isTypeContact = computed(() => props.type === 'contact');
+const isRemoveAction = computed(() => props.action === 'remove');
-const buttonLabel = computed(() =>
- props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : ''
+const buttonLabel = computed(() => {
+ if (!isTypeContact.value) return '';
+
+ return isRemoveAction.value
+ ? t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS')
+ : t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS');
+});
+
+const tooltipLabel = computed(() =>
+ isRemoveAction.value
+ ? t('BULK_ACTION.LABELS.REMOVE_LABELS')
+ : t('BULK_ACTION.LABELS.ASSIGN_LABELS')
+);
+
+const confirmLabel = computed(() =>
+ isRemoveAction.value
+ ? t('BULK_ACTION.LABELS.REMOVE_SELECTED_LABELS')
+ : t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')
);
const isLabelSelected = labelTitle => {
return selectedLabels.value.includes(labelTitle);
};
+const visibleLabels = computed(() => {
+ if (!isRemoveAction.value || props.appliedLabels === null) {
+ return labels.value;
+ }
+
+ const applied = new Set(props.appliedLabels);
+ return labels.value.filter(label => applied.has(label.title));
+});
+
const labelMenuItems = computed(() => {
- return labels.value.map(label => ({
+ return visibleLabels.value.map(label => ({
action: 'select',
value: label.title,
label: label.title,
@@ -64,9 +99,13 @@ const toggleLabelSelection = labelTitle => {
}
};
-const handleAssign = () => {
+const handleApply = () => {
if (selectedLabels.value.length > 0) {
- emit('assign', selectedLabels.value);
+ if (isRemoveAction.value) {
+ emit('remove', selectedLabels.value);
+ } else {
+ emit('assign', selectedLabels.value);
+ }
toggleDropdown(false);
selectedLabels.value = [];
}
@@ -81,9 +120,9 @@ const handleDismiss = () => {