- {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
+
+
+
+
+
+
+ {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
+
+
+
+
+
+
+ {{ playbackSpeedLabel }}
+
+
+
+
+
+
+
+
+
-
-
+
+
+ {{ attachment.transcribedText }}
-
-
- {{ playbackSpeedLabel }}
-
-
-
-
-
-
-
-
-
diff --git a/app/javascript/dashboard/i18n/locale/en/generalSettings.json b/app/javascript/dashboard/i18n/locale/en/generalSettings.json
index d243c0583..c0c4d247d 100644
--- a/app/javascript/dashboard/i18n/locale/en/generalSettings.json
+++ b/app/javascript/dashboard/i18n/locale/en/generalSettings.json
@@ -92,6 +92,32 @@
"PLACEHOLDER": "Your company's support email",
"ERROR": ""
},
+ "AUTO_RESOLVE_IGNORE_WAITING": {
+ "LABEL": "Exclude unattended conversations",
+ "HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agent's reply."
+ },
+ "AUDIO_TRANSCRIPTION": {
+ "TITLE": "Transcribe Audio Messages",
+ "NOTE": "Automatically transcribe audio messages in conversations. Generate a text transcript whenever an audio message is sent or received, and display it alongside the message.",
+ "API": {
+ "SUCCESS": "Audio transcription setting updated successfully",
+ "ERROR": "Failed to update audio transcription setting"
+ }
+ },
+ "AUTO_RESOLVE_DURATION": {
+ "LABEL": "Inactivity duration for resolution",
+ "HELP": "Duration after a conversation should auto resolve if there is no activity",
+ "PLACEHOLDER": "30",
+ "ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
+ "API": {
+ "SUCCESS": "Auto resolve settings updated successfully",
+ "ERROR": "Failed to update auto resolve settings"
+ },
+ "UPDATE_BUTTON": "Update",
+ "MESSAGE_LABEL": "Custom resolution message",
+ "MESSAGE_PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
+ "MESSAGE_HELP": "This message is sent to the customer when a conversation is automatically resolved by the system due to inactivity."
+ },
"FEATURES": {
"INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.",
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."
diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
index 5681cb0f9..cc66d829f 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue
@@ -16,6 +16,7 @@ import AccountId from './components/AccountId.vue';
import BuildInfo from './components/BuildInfo.vue';
import AccountDelete from './components/AccountDelete.vue';
import AutoResolve from './components/AutoResolve.vue';
+import AudioTranscription from './components/AudioTranscription.vue';
import SectionLayout from './components/SectionLayout.vue';
export default {
@@ -26,6 +27,7 @@ export default {
BuildInfo,
AccountDelete,
AutoResolve,
+ AudioTranscription,
SectionLayout,
WithLabel,
NextInput,
@@ -235,6 +237,7 @@ export default {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/components/AudioTranscription.vue b/app/javascript/dashboard/routes/dashboard/settings/account/components/AudioTranscription.vue
new file mode 100644
index 000000000..e0d561214
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/account/components/AudioTranscription.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/models/account.rb b/app/models/account.rb
index 80f13a1b8..f8eb998f0 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -36,6 +36,7 @@ class Account < ApplicationRecord
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
'auto_resolve_message': { 'type': %w[string null] },
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
+ 'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] }
},
'required': [],
@@ -52,7 +53,8 @@ class Account < ApplicationRecord
schema: SETTINGS_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.settings }
- store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label
+ store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
+ store_accessor :settings, :audio_transcriptions, :auto_resolve_label
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
diff --git a/app/models/attachment.rb b/app/models/attachment.rb
index 0bfd9a978..fd114c38c 100644
--- a/app/models/attachment.rb
+++ b/app/models/attachment.rb
@@ -44,11 +44,8 @@ class Attachment < ApplicationRecord
def push_event_data
return unless file_type
- return base_data.merge(location_metadata) if file_type.to_sym == :location
- return base_data.merge(fallback_data) if file_type.to_sym == :fallback
- return base_data.merge(contact_metadata) if file_type.to_sym == :contact
- base_data.merge(file_metadata)
+ base_data.merge(metadata_for_file_type)
end
# NOTE: the URl returned does a 301 redirect to the actual file
@@ -76,6 +73,30 @@ class Attachment < ApplicationRecord
private
+ def metadata_for_file_type
+ case file_type.to_sym
+ when :location
+ location_metadata
+ when :fallback
+ fallback_data
+ when :contact
+ contact_metadata
+ when :audio
+ audio_metadata
+ else
+ file_metadata
+ end
+ end
+
+ def audio_metadata
+ audio_file_data = base_data.merge(file_metadata)
+ audio_file_data.merge(
+ {
+ transcribed_text: meta&.[]('transcribed_text') || ''
+ }
+ )
+ end
+
def file_metadata
metadata = {
extension: extension,
@@ -149,3 +170,5 @@ class Attachment < ApplicationRecord
file_content_type.start_with?('image/', 'video/', 'audio/')
end
end
+
+Attachment.include_mod_with('Concerns::Attachment')
diff --git a/app/models/message.rb b/app/models/message.rb
index a952e0265..9381c33f6 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -224,6 +224,11 @@ class Message < ApplicationRecord
save!
end
+ def send_update_event
+ Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
+ previous_changes: previous_changes)
+ end
+
private
def prevent_message_flooding
@@ -313,8 +318,7 @@ class Message < ApplicationRecord
# we want to skip the update event if the message is not updated
return if previous_changes.blank?
- Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
- previous_changes: previous_changes)
+ send_update_event
end
def send_reply
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index c661caebe..53f134b15 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -49,10 +49,24 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def message_content(message)
return message.content if message.content.present?
+ return 'User has shared a message without content' unless message.attachments.any?
- return 'User has shared an attachment' if message.attachments.any?
+ audio_transcriptions = extract_audio_transcriptions(message.attachments)
+ return audio_transcriptions if audio_transcriptions.present?
- 'User has shared a message without content'
+ 'User has shared an attachment'
+ end
+
+ def extract_audio_transcriptions(attachments)
+ audio_attachments = attachments.where(file_type: :audio)
+ return '' if audio_attachments.blank?
+
+ transcriptions = ''
+ audio_attachments.each do |attachment|
+ result = Messages::AudioTranscriptionService.new(attachment).perform
+ transcriptions += result[:transcriptions] if result[:success]
+ end
+ transcriptions
end
def determine_role(message)
diff --git a/enterprise/app/jobs/messages/audio_transcription_job.rb b/enterprise/app/jobs/messages/audio_transcription_job.rb
new file mode 100644
index 000000000..a598cfafb
--- /dev/null
+++ b/enterprise/app/jobs/messages/audio_transcription_job.rb
@@ -0,0 +1,13 @@
+class Messages::AudioTranscriptionJob < ApplicationJob
+ queue_as :low
+
+ def perform(attachment_id)
+ attachment = Attachment.find_by(id: attachment_id)
+ return if attachment.blank?
+
+ Messages::AudioTranscriptionService.new(attachment).perform
+ rescue StandardError => e
+ Rails.logger.error "Error in AudioTranscriptionJob: #{e.message}"
+ ChatwootExceptionTracker.new(e).capture_exception
+ end
+end
diff --git a/enterprise/app/models/enterprise/concerns/attachment.rb b/enterprise/app/models/enterprise/concerns/attachment.rb
new file mode 100644
index 000000000..155dfcaad
--- /dev/null
+++ b/enterprise/app/models/enterprise/concerns/attachment.rb
@@ -0,0 +1,15 @@
+module Enterprise::Concerns::Attachment
+ extend ActiveSupport::Concern
+
+ included do
+ after_create_commit :enqueue_audio_transcription
+ end
+
+ private
+
+ def enqueue_audio_transcription
+ return unless file_type.to_sym == :audio
+
+ Messages::AudioTranscriptionJob.perform_later(id)
+ end
+end
diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb
new file mode 100644
index 000000000..49f1bd8c9
--- /dev/null
+++ b/enterprise/app/services/messages/audio_transcription_service.rb
@@ -0,0 +1,67 @@
+class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
+ attr_reader :attachment, :message, :account
+
+ def initialize(attachment)
+ super()
+ @attachment = attachment
+ @message = attachment.message
+ @account = message.account
+ end
+
+ def perform
+ return { error: 'Transcription limit exceeded' } unless can_transcribe?
+ return { error: 'Message not found' } if message.blank?
+
+ begin
+ transcriptions = transcribe_audio
+ Rails.logger.info "Audio transcription successful: #{transcriptions}"
+ { success: true, transcriptions: transcriptions }
+ rescue StandardError => e
+ ChatwootExceptionTracker.new(e).capture_exception
+ Rails.logger.error "Audio transcription failed: #{e.message}"
+ { error: "Transcription failed: #{e.message}" }
+ end
+ end
+
+ private
+
+ def can_transcribe?
+ account.audio_transcriptions.present? && account.usage_limits[:captain][:responses][:current_available].positive?
+ end
+
+ def fetch_audio_file
+ temp_dir = Rails.root.join('tmp/uploads')
+ FileUtils.mkdir_p(temp_dir)
+ temp_file_path = File.join(temp_dir, attachment.file.filename.to_s)
+ File.write(temp_file_path, attachment.file.download, mode: 'wb')
+ temp_file_path
+ end
+
+ def transcribe_audio
+ transcribed_text = attachment.meta&.[]('transcribed_text') || ''
+ return transcribed_text if transcribed_text.present?
+
+ temp_file_path = fetch_audio_file
+
+ response = @client.audio.transcribe(
+ parameters: {
+ model: 'whisper-1',
+ file: File.open(temp_file_path),
+ temperature: 0.4
+ }
+ )
+
+ FileUtils.rm_f(temp_file_path)
+
+ update_transcription(response['text'])
+ response['text']
+ end
+
+ def update_transcription(transcribed_text)
+ return if transcribed_text.blank?
+
+ attachment.update!(meta: { transcribed_text: transcribed_text })
+ message.reload.send_update_event
+ message.account.increment_response_usage
+ end
+end
diff --git a/spec/enterprise/jobs/messages/audio_transcription_job_spec.rb b/spec/enterprise/jobs/messages/audio_transcription_job_spec.rb
new file mode 100644
index 000000000..6133cf25b
--- /dev/null
+++ b/spec/enterprise/jobs/messages/audio_transcription_job_spec.rb
@@ -0,0 +1,41 @@
+require 'rails_helper'
+
+RSpec.describe Messages::AudioTranscriptionJob do
+ subject(:job) { described_class.perform_later(attachment_id) }
+
+ let(:message) { create(:message) }
+ let(:attachment) do
+ message.attachments.create!(
+ account_id: message.account_id,
+ file_type: :audio,
+ file: fixture_file_upload('public/audio/widget/ding.mp3')
+ )
+ end
+ let(:attachment_id) { attachment.id }
+ let(:conversation) { message.conversation }
+ let(:transcription_service) { instance_double(Messages::AudioTranscriptionService) }
+
+ it 'enqueues the job' do
+ expect { job }.to have_enqueued_job(described_class)
+ .with(attachment_id)
+ .on_queue('low')
+ end
+
+ context 'when performing the job' do
+ before do
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(attachment).and_return(transcription_service)
+ allow(transcription_service).to receive(:perform)
+ end
+
+ it 'calls AudioTranscriptionService with the attachment' do
+ expect(Messages::AudioTranscriptionService).to receive(:new).with(attachment)
+ expect(transcription_service).to receive(:perform)
+ described_class.perform_now(attachment_id)
+ end
+
+ it 'does nothing when attachment is not found' do
+ expect(Messages::AudioTranscriptionService).not_to receive(:new)
+ described_class.perform_now(999_999)
+ end
+ end
+end
diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
new file mode 100644
index 000000000..2e9e22728
--- /dev/null
+++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
@@ -0,0 +1,70 @@
+require 'rails_helper'
+
+RSpec.describe Messages::AudioTranscriptionService, type: :service do
+ let(:account) { create(:account, audio_transcriptions: true) }
+ let(:conversation) { create(:conversation, account: account) }
+ let(:message) { create(:message, conversation: conversation) }
+ let(:attachment) { message.attachments.create!(account: account, file_type: :audio) }
+
+ before do
+ # Create required installation configs
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-api-key')
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
+
+ # Mock usage limits for transcription to be available
+ allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } })
+ end
+
+ describe '#perform' do
+ let(:service) { described_class.new(attachment) }
+
+ context 'when transcription is successful' do
+ before do
+ # Mock can_transcribe? to return true and transcribe_audio method
+ allow(service).to receive(:can_transcribe?).and_return(true)
+ allow(service).to receive(:transcribe_audio).and_return('Hello world transcription')
+ end
+
+ it 'returns successful transcription' do
+ result = service.perform
+ expect(result).to eq({ success: true, transcriptions: 'Hello world transcription' })
+ end
+ end
+
+ context 'when audio transcriptions are disabled' do
+ before do
+ account.update!(audio_transcriptions: false)
+ end
+
+ it 'returns error for transcription limit exceeded' do
+ result = service.perform
+ expect(result).to eq({ error: 'Transcription limit exceeded' })
+ end
+ end
+
+ context 'when attachment already has transcribed text' do
+ before do
+ attachment.update!(meta: { transcribed_text: 'Existing transcription' })
+ allow(service).to receive(:can_transcribe?).and_return(true)
+ end
+
+ it 'returns existing transcription without calling API' do
+ result = service.perform
+ expect(result).to eq({ success: true, transcriptions: 'Existing transcription' })
+ end
+ end
+
+ context 'when transcription fails' do
+ before do
+ allow(service).to receive(:can_transcribe?).and_return(true)
+ allow(service).to receive(:transcribe_audio).and_raise(StandardError.new('API error'))
+ allow(ChatwootExceptionTracker).to receive(:new).and_return(instance_double(ChatwootExceptionTracker, capture_exception: nil))
+ end
+
+ it 'returns error response' do
+ result = service.perform
+ expect(result).to eq({ error: 'Transcription failed: API error' })
+ end
+ end
+ end
+end
From 27bce502101dc784ce19f5556626405e58f064c0 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Fri, 6 Jun 2025 06:08:56 +0530
Subject: [PATCH 4/7] fix: Incorrect date parsing in `matchesFilter` (#11679)
The `matchesFilter` is a utility that checks the incoming payload
against a filter and returns `true` or `false`.
For the `greater_than` and `less_than` filter specifically, the date
parsing would fail when the timestamp was a 10 digit number.
This PR solves this by adding a `coerceToDate` method that tries to
parse the given value to a Date object as correctly as possible before
comparing.
Ref: https://github.com/chatwoot/utils/pull/53
---
.../conversations/helpers/filterHelpers.js | 20 +-
.../helpers/specs/filterHelpers.spec.js | 235 ++++++++++++++++++
package.json | 2 +-
pnpm-lock.yaml | 10 +-
4 files changed, 259 insertions(+), 8 deletions(-)
diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
index 2591c2c01..d64d3c56b 100644
--- a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
+++ b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
@@ -47,6 +47,7 @@
* 3. Nested properties in custom_attributes (conversation_type, etc.)
*/
import jsonLogic from 'json-logic-js';
+import { coerceToDate } from '@chatwoot/utils';
/**
* Gets a value from a conversation based on the attribute key
@@ -157,6 +158,20 @@ const contains = (filterValue, conversationValue) => {
return false;
};
+/**
+ * Compares two date values using a comparison function
+ * @param {*} conversationValue - The conversation value to compare
+ * @param {*} filterValue - The filter value to compare against
+ * @param {Function} compareFn - The comparison function to apply
+ * @returns {Boolean} - Returns true if the comparison succeeds, false otherwise
+ */
+const compareDates = (conversationValue, filterValue, compareFn) => {
+ const conversationDate = coerceToDate(conversationValue);
+ const filterDate = coerceToDate(filterValue);
+ if (conversationDate === null || filterDate === null) return false;
+ return compareFn(conversationDate, filterDate);
+};
+
/**
* Checks if a value matches a filter condition
* @param {*} conversationValue - The value to check
@@ -195,10 +210,10 @@ const matchesCondition = (conversationValue, filter) => {
return false; // We already handled null/undefined above
case 'is_greater_than':
- return new Date(conversationValue) > new Date(filterValue);
+ return compareDates(conversationValue, filterValue, (a, b) => a > b);
case 'is_less_than':
- return new Date(conversationValue) < new Date(filterValue);
+ return compareDates(conversationValue, filterValue, (a, b) => a < b);
case 'days_before': {
const today = new Date();
@@ -347,6 +362,7 @@ export const matchesFilters = (conversation, filters) => {
conversation,
filters[0].attribute_key
);
+
return matchesCondition(value, filters[0]);
}
diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
index 6d709f085..0c9e6a5c0 100644
--- a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
+++ b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
@@ -463,6 +463,241 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true);
});
+ // Test conversation with 10-digit timestamp (seconds) vs standard date filter
+ it('should match conversation with 10-digit timestamp against date string filter', () => {
+ const conversation = { created_at: 1647777600 }; // March 20, 2022 in seconds (10 digits)
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: '2022-03-19', // Standard YYYY-MM-DD format
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Test conversation with 13-digit timestamp (milliseconds) vs standard date filter
+ it('should match conversation with 13-digit timestamp against date string filter', () => {
+ const conversation = { created_at: 1647777600000 }; // March 20, 2022 in milliseconds (13 digits)
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: '2022-03-19', // Standard YYYY-MM-DD format
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Test conversation with string timestamp vs standard date filter
+ it('should match conversation with string 10-digit timestamp against date string filter', () => {
+ const conversation = { created_at: '1647777600' }; // March 20, 2022 as string (10 digits)
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: '2022-03-19', // Standard YYYY-MM-DD format
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Test conversation with string 13-digit timestamp vs standard date filter
+ it('should match conversation with string 13-digit timestamp against date string filter', () => {
+ const conversation = { created_at: '1647777600000' }; // March 20, 2022 as string (13 digits)
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: '2022-03-19', // Standard YYYY-MM-DD format
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Test conversation with mixed format vs standard date filter with time
+ it('should match conversation with numeric timestamp against ISO date string filter', () => {
+ const conversation = { created_at: 1647777600000 }; // March 20, 2022 12:00:00 GMT (numeric)
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: '2022-03-19T10:30:00Z', // Standard ISO format from filter
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Test parseDate with date string without time (should default to 00:00:00)
+ it('should match conversation with is_greater_than operator using date string without time', () => {
+ const conversation = { created_at: 1647820800000 }; // March 21, 2022 00:00:00 GMT
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: '2022-03-20', // March 20, 2022 (should become 00:00:00)
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Test parseDate with ISO date string
+ it('should match conversation with is_greater_than operator using ISO date string', () => {
+ const conversation = { created_at: 1647777600000 }; // March 20, 2022
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: '2022-03-19T00:00:00.000Z', // March 19, 2022 ISO format
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Test parseDate with null/undefined values
+ it('should handle null filter values in date comparison', () => {
+ const conversation = { created_at: 1647777600000 }; // March 20, 2022
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: null,
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ it('should handle undefined filter values in date comparison', () => {
+ const conversation = { created_at: 1647777600000 }; // March 20, 2022
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: undefined,
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ // Test parseDate with invalid date strings
+ it('should handle invalid date strings in date comparison', () => {
+ const conversation = { created_at: 1647777600000 }; // March 20, 2022
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: 'invalid-date-string',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ it('should handle non-date string values in date comparison', () => {
+ const conversation = { created_at: 1647777600000 }; // March 20, 2022
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: 'not-a-date',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ // Test is_less_than with various date formats
+ it('should match conversation with is_less_than operator using numeric timestamp', () => {
+ const conversation = { created_at: 1647691200000 }; // March 19, 2022
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_less_than',
+ values: 1647777600, // March 20, 2022 as 10-digit timestamp
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should not match conversation with is_less_than operator when date is later', () => {
+ const conversation = { created_at: 1647864000000 }; // March 21, 2022
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_less_than',
+ values: '2022-03-20T12:00:00Z', // March 20, 2022 with time
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ // Edge case: Test with conversation having string timestamp
+ it('should handle conversation with string timestamp value', () => {
+ const conversation = { created_at: '1647777600000' }; // March 20, 2022 as string
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: '2022-03-19',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Edge case: Test with conversation having 10-digit timestamp
+ it('should handle conversation with 10-digit timestamp value', () => {
+ const conversation = { created_at: 1647777600 }; // March 20, 2022 as seconds (10 digits)
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: '2022-03-19',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Test date string with different time formats
+ it('should handle date string with space-separated time', () => {
+ const conversation = { created_at: 1647777600000 }; // March 20, 2022
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: '2022-03-19 10:30:00', // Date with space-separated time
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Test parseDate with object input (should return null and fail comparison)
+ it('should handle non-string, non-number filter values', () => {
+ const conversation = { created_at: 1647777600000 }; // March 20, 2022
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: { date: '2022-03-19' }, // Object instead of string/number
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
describe('days_before operator', () => {
beforeEach(() => {
// Set the date to March 25, 2022
diff --git a/package.json b/package.json
index 6280ec7fd..eb5098fa0 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.1.1-next",
- "@chatwoot/utils": "^0.0.45",
+ "@chatwoot/utils": "^0.0.46",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 606ebbb63..db7589569 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,8 +23,8 @@ importers:
specifier: 1.1.1-next
version: 1.1.1-next
'@chatwoot/utils':
- specifier: ^0.0.45
- version: 0.0.45
+ specifier: ^0.0.46
+ version: 0.0.46
'@formkit/core':
specifier: ^1.6.7
version: 1.6.7
@@ -406,8 +406,8 @@ packages:
'@chatwoot/prosemirror-schema@1.1.1-next':
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
- '@chatwoot/utils@0.0.45':
- resolution: {integrity: sha512-zqmuri6MrEFAY1tLv7Z3HBy4Ig60LhSrLkEiHegVsOVSxPv4Bedq+xmAW7LphvcLNgbkkvu17MU91gvMVlpEHw==}
+ '@chatwoot/utils@0.0.46':
+ resolution: {integrity: sha512-a68CQ+aPFfyMr7dnXUUSt/kwHEazBd7Y8aidDZeDp5eL7sych7EpmT5XMTmhttlqMiRsmwETblXJJ2fBH6I44A==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -5255,7 +5255,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.45':
+ '@chatwoot/utils@0.0.46':
dependencies:
date-fns: 2.30.0
From 10363e77ad238a5f6a80b4d929a4984b2102f4d9 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Thu, 5 Jun 2025 20:01:17 -0500
Subject: [PATCH 5/7] chore: Add region option to Dialogflow integration
(#11510)
## Summary
- support region option when configuring Dialogflow integration
- connect to region endpoint when set
- use session identification based on the region
Fixes: https://github.com/chatwoot/chatwoot/issues/4129
---
config/integration/apps.yml | 16 ++++-
.../dialogflow/processor_service.rb | 26 +++++++-
.../integrations/hooks_controller_spec.rb | 2 +-
spec/factories/integrations/hooks.rb | 2 +-
.../dialogflow/processor_service_spec.rb | 61 +++++++++++++++++++
5 files changed, 103 insertions(+), 4 deletions(-)
diff --git a/config/integration/apps.yml b/config/integration/apps.yml
index 2921bf637..1faf35670 100644
--- a/config/integration/apps.yml
+++ b/config/integration/apps.yml
@@ -84,6 +84,7 @@ dialogflow:
{
'project_id': { 'type': 'string' },
'credentials': { 'type': 'object' },
+ 'region': { 'type': 'string' },
},
'required': ['project_id', 'credentials'],
'additionalProperties': false,
@@ -106,8 +107,21 @@ dialogflow:
'validation-messages':
{ 'JSON': 'Invalid JSON', 'required': 'Credentials is required' },
},
+ {
+ 'label': 'Dialogflow Region',
+ 'type': 'select',
+ 'name': 'region',
+ 'default': 'global',
+ 'options': [
+ { 'label': 'Global - Default', 'value': 'global' },
+ { 'label': 'AS-NE1 - Tokyo, Japan', 'value': 'asia-northeast1' },
+ { 'label': 'AU-SE1 - Sydney, Australia', 'value': 'australia-southeast1' },
+ { 'label': 'EU-W1 - St. Ghislain, Belgium', 'value': 'europe-west1' },
+ { 'label': 'EU-W2 - London, England', 'value': 'europe-west2' },
+ ],
+ },
]
- visible_properties: ['project_id']
+ visible_properties: ['project_id', 'region']
google_translate:
id: google_translate
logo: google-translate.png
diff --git a/lib/integrations/dialogflow/processor_service.rb b/lib/integrations/dialogflow/processor_service.rb
index f3962a66c..578a277a2 100644
--- a/lib/integrations/dialogflow/processor_service.rb
+++ b/lib/integrations/dialogflow/processor_service.rb
@@ -65,13 +65,37 @@ class Integrations::Dialogflow::ProcessorService < Integrations::BotProcessorSer
::Google::Cloud::Dialogflow::V2::Sessions::Client.configure do |config|
config.timeout = 10.0
config.credentials = hook.settings['credentials']
+ config.endpoint = dialogflow_endpoint
end
end
+ def normalized_region
+ region = hook.settings['region'].to_s.strip
+ (region.presence || 'global')
+ end
+
+ def dialogflow_endpoint
+ region = normalized_region
+ return 'dialogflow.googleapis.com' if region == 'global'
+
+ "#{region}-dialogflow.googleapis.com"
+ end
+
def detect_intent(session_id, message)
client = ::Google::Cloud::Dialogflow::V2::Sessions::Client.new
- session = "projects/#{hook.settings['project_id']}/agent/sessions/#{session_id}"
+ session = build_session_path(session_id)
query_input = { text: { text: message, language_code: 'en-US' } }
client.detect_intent session: session, query_input: query_input
end
+
+ def build_session_path(session_id)
+ project_id = hook.settings['project_id']
+ region = normalized_region
+
+ if region == 'global'
+ "projects/#{project_id}/agent/sessions/#{session_id}"
+ else
+ "projects/#{project_id}/locations/#{region}/agent/sessions/#{session_id}"
+ end
+ end
end
diff --git a/spec/controllers/api/v1/accounts/integrations/hooks_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/hooks_controller_spec.rb
index 23c1045c5..5ca2633fc 100644
--- a/spec/controllers/api/v1/accounts/integrations/hooks_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/integrations/hooks_controller_spec.rb
@@ -5,7 +5,7 @@ RSpec.describe 'Integration Hooks API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
- let(:params) { { app_id: 'dialogflow', inbox_id: inbox.id, settings: { project_id: 'xx', credentials: { test: 'test' } } } }
+ let(:params) { { app_id: 'dialogflow', inbox_id: inbox.id, settings: { project_id: 'xx', credentials: { test: 'test' }, region: 'europe-west1' } } }
describe 'POST /api/v1/accounts/{account.id}/integrations/hooks' do
context 'when it is an unauthenticated user' do
diff --git a/spec/factories/integrations/hooks.rb b/spec/factories/integrations/hooks.rb
index c2d227e78..85517335a 100644
--- a/spec/factories/integrations/hooks.rb
+++ b/spec/factories/integrations/hooks.rb
@@ -9,7 +9,7 @@ FactoryBot.define do
trait :dialogflow do
app_id { 'dialogflow' }
- settings { { project_id: 'test', credentials: {} } }
+ settings { { project_id: 'test', credentials: {}, region: 'global' } }
end
trait :dyte do
diff --git a/spec/lib/integrations/dialogflow/processor_service_spec.rb b/spec/lib/integrations/dialogflow/processor_service_spec.rb
index 2b5f36c5a..3160f74d3 100644
--- a/spec/lib/integrations/dialogflow/processor_service_spec.rb
+++ b/spec/lib/integrations/dialogflow/processor_service_spec.rb
@@ -175,4 +175,65 @@ describe Integrations::Dialogflow::ProcessorService do
.to change(hook, :status).from('enabled').to('disabled')
end
end
+
+ describe 'region configuration' do
+ let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
+
+ context 'when region is global or not specified' do
+ it 'uses global endpoint and session path' do
+ hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {} })
+
+ expect(processor.send(:dialogflow_endpoint)).to eq('dialogflow.googleapis.com')
+ expect(processor.send(:build_session_path, 'test-session')).to eq('projects/test-project/agent/sessions/test-session')
+ end
+ end
+
+ context 'when region is specified' do
+ it 'uses regional endpoint and session path' do
+ hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'region' => 'europe-west1' })
+
+ expect(processor.send(:dialogflow_endpoint)).to eq('europe-west1-dialogflow.googleapis.com')
+ expect(processor.send(:build_session_path, 'test-session')).to eq('projects/test-project/locations/europe-west1/agent/sessions/test-session')
+ end
+ end
+
+ it 'configures client with correct endpoint' do
+ hook.update(settings: { 'project_id' => 'test', 'credentials' => {}, 'region' => 'europe-west1' })
+ config = OpenStruct.new
+ expect(Google::Cloud::Dialogflow::V2::Sessions::Client).to receive(:configure).and_yield(config)
+
+ processor.send(:configure_dialogflow_client_defaults)
+ expect(config.endpoint).to eq('europe-west1-dialogflow.googleapis.com')
+ end
+
+ context 'when calling detect_intent' do
+ let(:mock_client) { instance_double(Google::Cloud::Dialogflow::V2::Sessions::Client) }
+
+ before do
+ allow(Google::Cloud::Dialogflow::V2::Sessions::Client).to receive(:new).and_return(mock_client)
+ end
+
+ it 'uses global session path when region is not specified' do
+ hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {} })
+
+ expect(mock_client).to receive(:detect_intent).with(
+ session: 'projects/test-project/agent/sessions/test-session',
+ query_input: { text: { text: 'Hello', language_code: 'en-US' } }
+ )
+
+ processor.send(:detect_intent, 'test-session', 'Hello')
+ end
+
+ it 'uses regional session path when region is specified' do
+ hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'region' => 'europe-west1' })
+
+ expect(mock_client).to receive(:detect_intent).with(
+ session: 'projects/test-project/locations/europe-west1/agent/sessions/test-session',
+ query_input: { text: { text: 'Hello', language_code: 'en-US' } }
+ )
+
+ processor.send(:detect_intent, 'test-session', 'Hello')
+ end
+ end
+ end
end
From 9b43a0f72b3e37aa2b35de8f6175d94f2569878f Mon Sep 17 00:00:00 2001
From: Pranav
Date: Thu, 5 Jun 2025 22:53:11 -0500
Subject: [PATCH 6/7] fix: Retry job if file not found (#11683)
Removed StandardError rescue blocks and added retry_on for
ResponseBuilderJob and AudioTranscriptionJob
---
.../captain/conversation/response_builder_job.rb | 3 +++
.../app/jobs/messages/audio_transcription_job.rb | 5 ++---
.../messages/audio_transcription_service.rb | 12 +++---------
.../messages/audio_transcription_service_spec.rb | 13 -------------
4 files changed, 8 insertions(+), 25 deletions(-)
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index 53f134b15..eb62a9a38 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -1,5 +1,6 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
MAX_MESSAGE_LENGTH = 10_000
+ retry_on ActiveStorage::FileNotFoundError, attempts: 3
def perform(conversation, assistant)
@conversation = conversation
@@ -12,6 +13,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
generate_and_process_response
end
rescue StandardError => e
+ raise e if e.is_a?(ActiveJob::FileNotFoundError)
+
handle_error(e)
ensure
Current.executed_by = nil
diff --git a/enterprise/app/jobs/messages/audio_transcription_job.rb b/enterprise/app/jobs/messages/audio_transcription_job.rb
index a598cfafb..ce35405c8 100644
--- a/enterprise/app/jobs/messages/audio_transcription_job.rb
+++ b/enterprise/app/jobs/messages/audio_transcription_job.rb
@@ -1,13 +1,12 @@
class Messages::AudioTranscriptionJob < ApplicationJob
queue_as :low
+ retry_on ActiveStorage::FileNotFoundError, wait: 2.seconds, attempts: 3
+
def perform(attachment_id)
attachment = Attachment.find_by(id: attachment_id)
return if attachment.blank?
Messages::AudioTranscriptionService.new(attachment).perform
- rescue StandardError => e
- Rails.logger.error "Error in AudioTranscriptionJob: #{e.message}"
- ChatwootExceptionTracker.new(e).capture_exception
end
end
diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb
index 49f1bd8c9..8b598cf28 100644
--- a/enterprise/app/services/messages/audio_transcription_service.rb
+++ b/enterprise/app/services/messages/audio_transcription_service.rb
@@ -12,15 +12,9 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
return { error: 'Transcription limit exceeded' } unless can_transcribe?
return { error: 'Message not found' } if message.blank?
- begin
- transcriptions = transcribe_audio
- Rails.logger.info "Audio transcription successful: #{transcriptions}"
- { success: true, transcriptions: transcriptions }
- rescue StandardError => e
- ChatwootExceptionTracker.new(e).capture_exception
- Rails.logger.error "Audio transcription failed: #{e.message}"
- { error: "Transcription failed: #{e.message}" }
- end
+ transcriptions = transcribe_audio
+ Rails.logger.info "Audio transcription successful: #{transcriptions}"
+ { success: true, transcriptions: transcriptions }
end
private
diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
index 2e9e22728..78879e1a1 100644
--- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb
+++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
@@ -53,18 +53,5 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
expect(result).to eq({ success: true, transcriptions: 'Existing transcription' })
end
end
-
- context 'when transcription fails' do
- before do
- allow(service).to receive(:can_transcribe?).and_return(true)
- allow(service).to receive(:transcribe_audio).and_raise(StandardError.new('API error'))
- allow(ChatwootExceptionTracker).to receive(:new).and_return(instance_double(ChatwootExceptionTracker, capture_exception: nil))
- end
-
- it 'returns error response' do
- result = service.perform
- expect(result).to eq({ error: 'Transcription failed: API error' })
- end
- end
end
end
From 25f947223d2c231759e326c9a9ebff586c9b26a5 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Mon, 9 Jun 2025 14:46:12 +0530
Subject: [PATCH 7/7] feat: sanitize inbox name (#11597)
Co-authored-by: Muhsin Keloth
---
app/controllers/oauth_callback_controller.rb | 1 +
.../conversation_notifications_mailer.rb | 3 +-
app/mailers/conversation_reply_mailer.rb | 2 +-
app/models/inbox.rb | 28 +++-
.../conversation_notifications_mailer_spec.rb | 2 +-
.../mailers/conversation_reply_mailer_spec.rb | 36 ++--
spec/models/inbox_spec.rb | 156 +++++++++++++++---
7 files changed, 180 insertions(+), 48 deletions(-)
diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb
index 4cb02d266..9aa73956a 100644
--- a/app/controllers/oauth_callback_controller.rb
+++ b/app/controllers/oauth_callback_controller.rb
@@ -71,6 +71,7 @@ class OauthCallbackController < ApplicationController
def create_channel_with_inbox
ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
+
account.inboxes.create!(
account: account,
channel: channel_email,
diff --git a/app/mailers/agent_notifications/conversation_notifications_mailer.rb b/app/mailers/agent_notifications/conversation_notifications_mailer.rb
index a728bbbc6..8bf27220e 100644
--- a/app/mailers/agent_notifications/conversation_notifications_mailer.rb
+++ b/app/mailers/agent_notifications/conversation_notifications_mailer.rb
@@ -4,7 +4,8 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
@agent = agent
@conversation = conversation
- subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{@conversation.inbox&.name}."
+ inbox_name = @conversation.inbox&.sanitized_name
+ subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{inbox_name}."
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: subject) and return
end
diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb
index cba95cb69..ba46a5fed 100644
--- a/app/mailers/conversation_reply_mailer.rb
+++ b/app/mailers/conversation_reply_mailer.rb
@@ -104,7 +104,7 @@ class ConversationReplyMailer < ApplicationMailer
end
def business_name
- @inbox.business_name || @inbox.name
+ @inbox.business_name || @inbox.sanitized_name
end
def from_email
diff --git a/app/models/inbox.rb b/app/models/inbox.rb
index f1343a352..69996707b 100644
--- a/app/models/inbox.rb
+++ b/app/models/inbox.rb
@@ -47,8 +47,6 @@ class Inbox < ApplicationRecord
# Not allowing characters:
validates :name, presence: true
- validates :name, if: :check_channel_type?, format: { with: %r{^^\b[^/\\<>@]*\b$}, multiline: true,
- message: I18n.t('errors.inboxes.validations.name') }
validates :account_id, presence: true
validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers }
validates :out_of_office_message, length: { maximum: Limits::OUT_OF_OFFICE_MESSAGE_MAX_LENGTH }
@@ -99,6 +97,16 @@ class Inbox < ApplicationRecord
update_account_cache
end
+ # Sanitizes inbox name for balanced email provider compatibility
+ # ALLOWS: /'._- and Unicode letters/numbers/emojis
+ # REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
+ def sanitized_name
+ return default_name_for_blank_name if name.blank?
+
+ sanitized = apply_sanitization_rules(name)
+ sanitized.blank? && email? ? display_name_from_email : sanitized
+ end
+
def sms?
channel_type == 'Channel::Sms'
end
@@ -178,6 +186,22 @@ class Inbox < ApplicationRecord
private
+ def default_name_for_blank_name
+ email? ? display_name_from_email : ''
+ end
+
+ def apply_sanitization_rules(name)
+ name.gsub(/[\\<>@"!#$%&*+=?^`{|}~]/, '') # Remove forbidden chars
+ .gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
+ .gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
+ .gsub(/\s+/, ' ') # Normalize spaces
+ .strip
+ end
+
+ def display_name_from_email
+ channel.email.split('@').first.parameterize.titleize
+ end
+
def dispatch_create_event
return if ENV['ENABLE_INBOX_EVENTS'].blank?
diff --git a/spec/mailers/agent_notifications/conversation_notifications_mailer_spec.rb b/spec/mailers/agent_notifications/conversation_notifications_mailer_spec.rb
index 5661af714..5310e8b10 100644
--- a/spec/mailers/agent_notifications/conversation_notifications_mailer_spec.rb
+++ b/spec/mailers/agent_notifications/conversation_notifications_mailer_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe AgentNotifications::ConversationNotificationsMailer do
it 'renders the subject' do
expect(mail.subject).to eq("#{agent.available_name}, A new conversation [ID - #{conversation
- .display_id}] has been created in #{conversation.inbox&.name}.")
+ .display_id}] has been created in #{conversation.inbox&.sanitized_name}.")
end
it 'renders the receiver email' do
diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb
index 28bc55385..8485fcf7a 100644
--- a/spec/mailers/conversation_reply_mailer_spec.rb
+++ b/spec/mailers/conversation_reply_mailer_spec.rb
@@ -87,7 +87,7 @@ RSpec.describe ConversationReplyMailer do
let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now }
it 'has correct name' do
- expect(mail[:from].display_names).to eq(["#{message.sender.available_name} from Inbox"])
+ expect(mail[:from].display_names).to eq(["#{message.sender.available_name} from #{message.conversation.inbox.sanitized_name}"])
end
end
@@ -224,11 +224,11 @@ RSpec.describe ConversationReplyMailer do
end
context 'when smtp enabled for email channel' do
- let(:smtp_email_channel) do
+ let(:smtp_channel) do
create(:channel_email, smtp_enabled: true, smtp_address: 'smtp.gmail.com', smtp_port: 587, smtp_login: 'smtp@gmail.com',
smtp_password: 'password', smtp_domain: 'smtp.gmail.com', account: account)
end
- let(:conversation) { create(:conversation, assignee: agent, inbox: smtp_email_channel.inbox, account: account).reload }
+ let(:conversation) { create(:conversation, assignee: agent, inbox: smtp_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
it 'use smtp mail server' do
@@ -240,19 +240,19 @@ RSpec.describe ConversationReplyMailer do
it 'renders sender name in the from address' do
mail = described_class.email_reply(message)
- expect(mail['from'].value).to eq "#{message.sender.available_name} from #{smtp_email_channel.inbox.name} <#{smtp_email_channel.email}>"
+ expect(mail['from'].value).to eq "#{message.sender.available_name} from #{smtp_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
end
it 'renders sender name even when assignee is not present' do
conversation.update(assignee_id: nil)
mail = described_class.email_reply(message)
- expect(mail['from'].value).to eq "#{message.sender.available_name} from #{smtp_email_channel.inbox.name} <#{smtp_email_channel.email}>"
+ expect(mail['from'].value).to eq "#{message.sender.available_name} from #{smtp_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
end
it 'renders assignee name in the from address when sender_name not available' do
message.update(sender_id: nil)
mail = described_class.email_reply(message)
- expect(mail['from'].value).to eq "#{conversation.assignee.available_name} from #{smtp_email_channel.inbox.name} <#{smtp_email_channel.email}>"
+ expect(mail['from'].value).to eq "#{conversation.assignee.available_name} from #{smtp_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
end
it 'renders inbox name as sender and assignee or business_name not present' do
@@ -260,7 +260,7 @@ RSpec.describe ConversationReplyMailer do
conversation.update(assignee_id: nil)
mail = described_class.email_reply(message)
- expect(mail['from'].value).to eq "Notifications from #{smtp_email_channel.inbox.name} <#{smtp_email_channel.email}>"
+ expect(mail['from'].value).to eq "Notifications from #{smtp_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
end
context 'when friendly name enabled' do
@@ -276,7 +276,7 @@ RSpec.describe ConversationReplyMailer do
mail = described_class.email_reply(message)
- expect(mail['from'].value).to eq "Notifications from #{conversation.inbox.name} <#{smtp_email_channel.email}>"
+ expect(mail['from'].value).to eq "Notifications from #{conversation.inbox.sanitized_name} <#{smtp_channel.email}>"
end
it 'renders sender name as sender and assignee nil and business_name present' do
@@ -286,7 +286,7 @@ RSpec.describe ConversationReplyMailer do
mail = described_class.email_reply(message)
expect(mail['from'].value).to eq(
- "Notifications from #{conversation.inbox.business_name} <#{smtp_email_channel.email}>"
+ "Notifications from #{conversation.inbox.business_name} <#{smtp_channel.email}>"
)
end
@@ -295,7 +295,7 @@ RSpec.describe ConversationReplyMailer do
conversation.update(assignee_id: agent.id)
mail = described_class.email_reply(message)
- expect(mail['from'].value).to eq "#{agent.available_name} from #{conversation.inbox.business_name} <#{smtp_email_channel.email}>"
+ expect(mail['from'].value).to eq "#{agent.available_name} from #{conversation.inbox.business_name} <#{smtp_channel.email}>"
end
it 'renders sender name as sender and assignee and business_name present' do
@@ -304,7 +304,7 @@ RSpec.describe ConversationReplyMailer do
conversation.update(assignee_id: agent.id)
mail = described_class.email_reply(message)
- expect(mail['from'].value).to eq "#{agent_2.available_name} from #{conversation.inbox.business_name} <#{smtp_email_channel.email}>"
+ expect(mail['from'].value).to eq "#{agent_2.available_name} from #{conversation.inbox.business_name} <#{smtp_channel.email}>"
end
end
@@ -321,7 +321,7 @@ RSpec.describe ConversationReplyMailer do
mail = described_class.email_reply(message)
- expect(mail['from'].value).to eq "#{conversation.inbox.name} <#{smtp_email_channel.email}>"
+ expect(mail['from'].value).to eq "#{conversation.inbox.sanitized_name} <#{smtp_channel.email}>"
end
it 'renders sender name as business_name present' do
@@ -330,17 +330,17 @@ RSpec.describe ConversationReplyMailer do
mail = described_class.email_reply(message)
- expect(mail['from'].value).to eq "#{conversation.inbox.business_name} <#{smtp_email_channel.email}>"
+ expect(mail['from'].value).to eq "#{conversation.inbox.business_name} <#{smtp_channel.email}>"
end
end
end
context 'when smtp enabled for microsoft email channel' do
- let(:ms_smtp_email_channel) do
+ let(:ms_smtp_channel) do
create(:channel_email, imap_login: 'smtp@outlook.com',
imap_enabled: true, account: account, provider: 'microsoft', provider_config: { access_token: 'access_token' })
end
- let(:conversation) { create(:conversation, assignee: agent, inbox: ms_smtp_email_channel.inbox, account: account).reload }
+ let(:conversation) { create(:conversation, assignee: agent, inbox: ms_smtp_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
it 'use smtp mail server' do
@@ -352,11 +352,11 @@ RSpec.describe ConversationReplyMailer do
end
context 'when smtp enabled for google email channel' do
- let(:ms_smtp_email_channel) do
+ let(:ms_smtp_channel) do
create(:channel_email, imap_login: 'smtp@gmail.com',
imap_enabled: true, account: account, provider: 'google', provider_config: { access_token: 'access_token' })
end
- let(:conversation) { create(:conversation, assignee: agent, inbox: ms_smtp_email_channel.inbox, account: account).reload }
+ let(:conversation) { create(:conversation, assignee: agent, inbox: ms_smtp_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
it 'use smtp mail server' do
@@ -430,7 +430,7 @@ RSpec.describe ConversationReplyMailer do
it 'sets reply to email to be based on the domain' do
reply_to_email = "reply+#{message.conversation.uuid}@#{conversation.account.domain}"
- reply_to = "#{message.sender.available_name} from #{conversation.inbox.name} <#{reply_to_email}>"
+ reply_to = "#{message.sender.available_name} from #{conversation.inbox.sanitized_name} <#{reply_to_email}>"
expect(mail['REPLY-TO'].value).to eq(reply_to)
expect(mail.reply_to).to eq([reply_to_email])
end
diff --git a/spec/models/inbox_spec.rb b/spec/models/inbox_spec.rb
index 02d68d77a..e25e87e4d 100644
--- a/spec/models/inbox_spec.rb
+++ b/spec/models/inbox_spec.rb
@@ -164,31 +164,7 @@ RSpec.describe Inbox do
let(:inbox) { FactoryBot.create(:inbox) }
context 'when validating inbox name' do
- it 'does not allow any special character at the end' do
- inbox.name = 'this is my inbox name-'
- expect(inbox).not_to be_valid
- expect(inbox.errors.full_messages).to eq(
- ['Name should not start or end with symbols, and it should not have < > / \\ @ characters.']
- )
- end
-
- it 'does not allow any special character at the start' do
- inbox.name = '-this is my inbox name'
- expect(inbox).not_to be_valid
- expect(inbox.errors.full_messages).to eq(
- ['Name should not start or end with symbols, and it should not have < > / \\ @ characters.']
- )
- end
-
- it 'does not allow chacters like /\@<> in the entire string' do
- inbox.name = 'inbox@name'
- expect(inbox).not_to be_valid
- expect(inbox.errors.full_messages).to eq(
- ['Name should not start or end with symbols, and it should not have < > / \\ @ characters.']
- )
- end
-
- it 'does not empty string' do
+ it 'does not allow empty string' do
inbox.name = ''
expect(inbox).not_to be_valid
expect(inbox.errors.full_messages[0]).to eq(
@@ -282,4 +258,134 @@ RSpec.describe Inbox do
inbox.touch # rubocop:disable Rails/SkipsModelValidations
end
end
+
+ describe '#sanitized_name' do
+ context 'when inbox name contains forbidden characters' do
+ it 'removes forbidden and spam-trigger characters' do
+ inbox = FactoryBot.build(:inbox, name: 'Test/Name\\With@Characters"And\'Quotes!#$%')
+ expect(inbox.sanitized_name).to eq('Test/NameWithBadCharactersAnd\'Quotes')
+ end
+ end
+
+ context 'when inbox name has leading/trailing non-word characters' do
+ it 'removes leading and trailing symbols' do
+ inbox = FactoryBot.build(:inbox, name: '!!!Test Name***')
+ expect(inbox.sanitized_name).to eq('Test Name')
+ end
+
+ it 'handles mixed leading/trailing characters' do
+ inbox = FactoryBot.build(:inbox, name: '###@@@Test Inbox Name$$$%%')
+ expect(inbox.sanitized_name).to eq('Test Inbox Name')
+ end
+ end
+
+ context 'when inbox name has multiple spaces' do
+ it 'normalizes multiple spaces to single space' do
+ inbox = FactoryBot.build(:inbox, name: 'Test Multiple Spaces')
+ expect(inbox.sanitized_name).to eq('Test Multiple Spaces')
+ end
+
+ it 'handles tabs and other whitespace' do
+ inbox = FactoryBot.build(:inbox, name: "Test\t\nMultiple\r\nSpaces")
+ expect(inbox.sanitized_name).to eq('Test Multiple Spaces')
+ end
+ end
+
+ context 'when inbox name has leading/trailing whitespace' do
+ it 'strips whitespace' do
+ inbox = FactoryBot.build(:inbox, name: ' Test Name ')
+ expect(inbox.sanitized_name).to eq('Test Name')
+ end
+ end
+
+ context 'when inbox name becomes empty after sanitization' do
+ context 'with email channel' do
+ it 'falls back to email local part' do
+ email_channel = FactoryBot.build(:channel_email, email: 'support@example.com')
+ inbox = FactoryBot.build(:inbox, name: '\\<>@"', channel: email_channel)
+ expect(inbox.sanitized_name).to eq('Support')
+ end
+
+ it 'handles email with complex local part' do
+ email_channel = FactoryBot.build(:channel_email, email: 'help-desk_team@example.com')
+ inbox = FactoryBot.build(:inbox, name: '!!!@@@', channel: email_channel)
+ expect(inbox.sanitized_name).to eq('Help Desk Team')
+ end
+ end
+
+ context 'with non-email channel' do
+ it 'returns empty string when name becomes blank' do
+ web_widget_channel = FactoryBot.build(:channel_widget)
+ inbox = FactoryBot.build(:inbox, name: '\\<>@"', channel: web_widget_channel)
+ expect(inbox.sanitized_name).to eq('')
+ end
+ end
+ end
+
+ context 'when inbox name is blank initially' do
+ context 'with email channel' do
+ it 'uses email local part as fallback' do
+ email_channel = FactoryBot.build(:channel_email, email: 'customer-care@example.com')
+ inbox = FactoryBot.build(:inbox, name: '', channel: email_channel)
+ expect(inbox.sanitized_name).to eq('Customer Care')
+ end
+ end
+
+ context 'with non-email channel' do
+ it 'returns empty string' do
+ api_channel = FactoryBot.build(:channel_api)
+ inbox = FactoryBot.build(:inbox, name: '', channel: api_channel)
+ expect(inbox.sanitized_name).to eq('')
+ end
+ end
+ end
+
+ context 'when inbox name contains valid characters' do
+ it 'preserves valid characters like hyphens, underscores, and dots' do
+ inbox = FactoryBot.build(:inbox, name: 'Test-Name_With.Valid-Characters')
+ expect(inbox.sanitized_name).to eq('Test-Name_With.Valid-Characters')
+ end
+
+ it 'preserves alphanumeric characters and spaces' do
+ inbox = FactoryBot.build(:inbox, name: 'Customer Support 123')
+ expect(inbox.sanitized_name).to eq('Customer Support 123')
+ end
+
+ it 'preserves balanced safe characters but removes spam-trigger symbols' do
+ inbox = FactoryBot.build(:inbox, name: "Test!#$%&'*+/=?^_`{|}~-Name")
+ expect(inbox.sanitized_name).to eq("Test'/_-Name")
+ end
+
+ it 'keeps commonly used safe characters' do
+ inbox = FactoryBot.build(:inbox, name: "Support/Help's Team.Desk_2024-Main")
+ expect(inbox.sanitized_name).to eq("Support/Help's Team.Desk_2024-Main")
+ end
+ end
+
+ context 'when inbox name contains problematic characters for email headers' do
+ it 'preserves Unicode symbols (trademark, etc.)' do
+ inbox = FactoryBot.build(:inbox, name: 'Test™Name®With©Special™Characters')
+ expect(inbox.sanitized_name).to eq('Test™Name®With©Special™Characters')
+ end
+ end
+
+ context 'with edge cases' do
+ it 'handles nil name gracefully' do
+ inbox = FactoryBot.build(:inbox)
+ allow(inbox).to receive(:name).and_return(nil)
+ expect { inbox.sanitized_name }.not_to raise_error
+ end
+
+ it 'handles very long names' do
+ long_name = 'A' * 1000
+ inbox = FactoryBot.build(:inbox, name: long_name)
+ expect(inbox.sanitized_name).to eq(long_name)
+ end
+
+ it 'handles unicode characters and preserves emojis' do
+ inbox = FactoryBot.build(:inbox, name: 'Test Name with émojis 🎉')
+ expect(inbox.sanitized_name).to eq('Test Name with émojis 🎉')
+ end
+ end
+ end
end