From 513d9540270e79034456821b1706f92b0ce84dd1 Mon Sep 17 00:00:00 2001 From: raza-ak <116057378+raza-ak@users.noreply.github.com> Date: Wed, 4 Jun 2025 21:22:13 +0500 Subject: [PATCH 01/11] feat: Show active Contacts (#8243) --- .../api/v1/accounts/contacts_controller.rb | 4 +- app/javascript/dashboard/api/contacts.js | 5 ++ .../Contacts/ContactsCard/ContactsCard.vue | 9 ++- .../Contacts/ContactsHeader/ContactHeader.vue | 57 ++++++------------- .../ContactListHeaderWrapper.vue | 2 + .../Contacts/ContactsListLayout.vue | 24 +++++--- .../Contacts/Pages/ContactsList.vue | 1 + .../components-next/sidebar/Sidebar.vue | 6 ++ .../layout/config/sidebarItems/contacts.js | 8 +++ .../dashboard/i18n/locale/en/contact.json | 4 +- .../dashboard/i18n/locale/en/settings.json | 1 + .../contacts/pages/ContactsIndex.vue | 49 ++++++++++++++-- .../routes/dashboard/contacts/routes.js | 6 ++ .../store/modules/contacts/actions.js | 15 +++++ .../modules/specs/contacts/actions.spec.js | 24 ++++++++ 15 files changed, 159 insertions(+), 56 deletions(-) diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index b4d5e3fc1..4fbe50902 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController before_action :check_authorization before_action :set_current_page, only: [:index, :active, :search, :filter] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] - before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update] + before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] def index @contacts_count = resolved_contacts.count @@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController contacts = Current.account.contacts.where(id: ::OnlineStatusTracker .get_available_contact_ids(Current.account.id)) @contacts_count = contacts.count - @contacts = contacts.page(@current_page) + @contacts = fetch_contacts(contacts) end def show; end diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 2eee3f484..025df2122 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -61,6 +61,11 @@ class ContactAPI extends ApiClient { return axios.get(requestURL); } + active(page = 1, sortAttr = 'name') { + let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`; + return axios.get(requestURL); + } + // eslint-disable-next-line default-param-last filter(page = 1, sortAttr = 'name', queryPayload) { let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`; diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index b2b0dbfa0..0932a79c7 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -17,6 +17,7 @@ const props = defineProps({ additionalAttributes: { type: Object, default: () => ({}) }, phoneNumber: { type: String, default: '' }, thumbnail: { type: String, default: '' }, + availabilityStatus: { type: String, default: null }, isExpanded: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false }, }); @@ -92,7 +93,13 @@ const onClickViewDetails = () => emit('showContact', props.id); diff --git a/app/javascript/dashboard/helper/auditlogHelper.js b/app/javascript/dashboard/helper/auditlogHelper.js index 6f5a4dede..706d09ba2 100644 --- a/app/javascript/dashboard/helper/auditlogHelper.js +++ b/app/javascript/dashboard/helper/auditlogHelper.js @@ -36,6 +36,7 @@ const translationKeys = { 'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`, 'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`, 'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`, + 'conversation:destroy': `AUDIT_LOGS.CONVERSATION.DELETE`, }; function extractAttrChange(attrChange) { @@ -168,6 +169,11 @@ export function generateTranslationPayload(auditLogItem, agentList) { const auditableType = auditLogItem.auditable_type.toLowerCase(); const action = auditLogItem.action.toLowerCase(); + if (auditableType === 'conversation' && action === 'destroy') { + translationPayload.id = + auditLogItem.audited_changes?.display_id || auditLogItem.auditable_id; + } + if (auditableType === 'accountuser') { translationPayload = handleAccountUser( auditLogItem, diff --git a/app/javascript/dashboard/i18n/locale/en/auditLogs.json b/app/javascript/dashboard/i18n/locale/en/auditLogs.json index 8194c667c..f85ad2a3e 100644 --- a/app/javascript/dashboard/i18n/locale/en/auditLogs.json +++ b/app/javascript/dashboard/i18n/locale/en/auditLogs.json @@ -69,6 +69,9 @@ }, "ACCOUNT": { "EDIT": "{agentName} updated the account configuration (#{id})" + }, + "CONVERSATION": { + "DELETE": "{agentName} deleted conversation #{id}" } } } diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 26ec6dc16..fdb7fc07a 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -118,6 +118,11 @@ "FAILED": "Couldn't change priority. Please try again." } }, + "DELETE_CONVERSATION": { + "TITLE": "Delete conversation #{conversationId}", + "DESCRIPTION": "Are you sure you want to delete this conversation?", + "CONFIRM": "Delete" + }, "CARD_CONTEXT_MENU": { "PENDING": "Mark as pending", "RESOLVED": "Mark as resolved", @@ -134,6 +139,7 @@ "ASSIGN_LABEL": "Assign label", "AGENTS_LOADING": "Loading agents...", "ASSIGN_TEAM": "Assign team", + "DELETE": "Delete conversation", "API": { "AGENT_ASSIGNMENT": { "SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"", @@ -208,6 +214,8 @@ "ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully", "ASSIGN_LABEL_FAILED": "Label assignment failed", "CHANGE_TEAM": "Conversation team changed", + "SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully", + "FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again", "FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit", "MESSAGE_ERROR": "Unable to send this message, please try again later", "SENT_BY": "Sent by:", diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 8296f25de..d8ad826ca 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -327,6 +327,16 @@ const actions = { } }, + deleteConversation: async ({ commit, dispatch }, conversationId) => { + try { + await ConversationApi.delete(conversationId); + commit(types.DELETE_CONVERSATION, conversationId); + dispatch('conversationStats/get', {}, { root: true }); + } catch (error) { + throw new Error(error); + } + }, + addConversation({ commit, state, dispatch, rootState }, conversation) { const { currentInbox, appliedFilters } = state; const { diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index fc6f10979..2ee6fd061 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -204,6 +204,12 @@ export const mutations = { _state.allConversations.push(conversation); }, + [types.DELETE_CONVERSATION](_state, conversationId) { + _state.allConversations = _state.allConversations.filter( + c => c.id !== conversationId + ); + }, + [types.UPDATE_CONVERSATION](_state, conversation) { const { allConversations } = _state; const index = allConversations.findIndex(c => c.id === conversation.id); diff --git a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js index dc2bf8d3a..161ae914b 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js @@ -513,6 +513,28 @@ describe('#deleteMessage', () => { expect(commit.mock.calls).toEqual([]); }); + describe('#deleteConversation', () => { + it('send correct actions if API is success', async () => { + axios.delete.mockResolvedValue({ + data: { id: 1 }, + }); + await actions.deleteConversation({ commit, dispatch }, 1); + expect(commit.mock.calls).toEqual([[types.DELETE_CONVERSATION, 1]]); + expect(dispatch.mock.calls).toEqual([ + ['conversationStats/get', {}, { root: true }], + ]); + }); + + it('send no actions if API is error', async () => { + axios.delete.mockRejectedValue({ message: 'Incorrect header' }); + await expect( + actions.deleteConversation({ commit, dispatch }, 1) + ).rejects.toThrow(Error); + expect(commit.mock.calls).toEqual([]); + expect(dispatch.mock.calls).toEqual([]); + }); + }); + describe('#updateCustomAttributes', () => { it('update conversation custom attributes', async () => { axios.post.mockResolvedValue({ diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js index 091dc3daa..b8660b20d 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js @@ -884,6 +884,17 @@ describe('#mutations', () => { }); }); + describe('#DELETE_CONVERSATION', () => { + it('should delete a conversation', () => { + const state = { + allConversations: [{ id: 1, messages: [] }], + }; + + mutations[types.DELETE_CONVERSATION](state, 1); + expect(state.allConversations).toEqual([]); + }); + }); + describe('#SET_LIST_LOADING_STATUS', () => { it('should set listLoadingStatus to true', () => { const state = { diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index f3817c45a..a63fec2d1 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -56,6 +56,7 @@ export default { SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS', ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS', DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS', + DELETE_CONVERSATION: 'DELETE_CONVERSATION', SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY', diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 3f1a20037..922118b09 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -305,5 +305,6 @@ class Conversation < ApplicationRecord end end +Conversation.include_mod_with('Audit::Conversation') Conversation.include_mod_with('Concerns::Conversation') Conversation.prepend_mod_with('Conversation') diff --git a/app/policies/conversation_policy.rb b/app/policies/conversation_policy.rb index 133cb3b02..931e17435 100644 --- a/app/policies/conversation_policy.rb +++ b/app/policies/conversation_policy.rb @@ -2,4 +2,8 @@ class ConversationPolicy < ApplicationPolicy def index? true end + + def destroy? + @account_user&.administrator? + end end diff --git a/config/routes.rb b/config/routes.rb index ebec6894b..12ce70d77 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -98,7 +98,7 @@ Rails.application.routes.draw do namespace :channels do resource :twilio_channel, only: [:create] end - resources :conversations, only: [:index, :create, :show, :update] do + resources :conversations, only: [:index, :create, :show, :update, :destroy] do collection do get :meta get :search diff --git a/enterprise/app/jobs/enterprise/delete_object_job.rb b/enterprise/app/jobs/enterprise/delete_object_job.rb index d7a7f2f17..147fa0d43 100644 --- a/enterprise/app/jobs/enterprise/delete_object_job.rb +++ b/enterprise/app/jobs/enterprise/delete_object_job.rb @@ -4,7 +4,7 @@ module Enterprise::DeleteObjectJob end def create_audit_entry(object, user, ip) - return unless ['Inbox'].include?(object.class.to_s) && user.present? + return unless %w[Inbox Conversation].include?(object.class.to_s) && user.present? Enterprise::AuditLog.create( auditable: object, diff --git a/enterprise/app/models/enterprise/audit/conversation.rb b/enterprise/app/models/enterprise/audit/conversation.rb new file mode 100644 index 000000000..b1e096beb --- /dev/null +++ b/enterprise/app/models/enterprise/audit/conversation.rb @@ -0,0 +1,7 @@ +module Enterprise::Audit::Conversation + extend ActiveSupport::Concern + + included do + audited only: [], on: [:destroy] + end +end diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index a3697be84..38bd649fe 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -926,4 +926,63 @@ RSpec.describe 'Conversations API', type: :request do end end end + + describe 'DELETE /api/v1/accounts/{account.id}/conversations/:id' do + let(:conversation) { create(:conversation, account: account) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated agent' do + before do + create(:inbox_member, user: agent, inbox: conversation.inbox) + end + + it 'returns unauthorized' do + delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + response_body = response.parsed_body + expect(response_body['error']).to eq('You are not authorized to do this action') + end + end + + context 'when it is an authenticated administrator' do + before do + create(:inbox_member, user: administrator, inbox: conversation.inbox) + end + + it 'successfully deletes the conversation' do + expect do + delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", + headers: administrator.create_new_auth_token, + as: :json + end.to have_enqueued_job(DeleteObjectJob).with(conversation, administrator, anything) + + expect(response).to have_http_status(:ok) + end + + it 'can delete conversations from inboxes without direct access' do + other_inbox = create(:inbox, account: account) + other_conversation = create(:conversation, account: account, inbox: other_inbox) + + expect do + delete "/api/v1/accounts/#{account.id}/conversations/#{other_conversation.display_id}", + headers: administrator.create_new_auth_token, + as: :json + end.to have_enqueued_job(DeleteObjectJob).with(other_conversation, administrator, anything) + + expect(response).to have_http_status(:ok) + end + end + end end diff --git a/spec/models/enterprise/audit/conversation_spec.rb b/spec/models/enterprise/audit/conversation_spec.rb new file mode 100644 index 000000000..56ea2910d --- /dev/null +++ b/spec/models/enterprise/audit/conversation_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe 'Conversation Audit', type: :model do + let(:account) { create(:account) } + let(:conversation) { create(:conversation, account: account) } + + before do + # Enable auditing for conversations + conversation.class.send(:include, Enterprise::Audit::Conversation) if defined?(Enterprise::Audit::Conversation) + end + + describe 'audit logging on destroy' do + it 'creates an audit log when conversation is destroyed' do + skip 'Enterprise audit module not available' unless defined?(Enterprise::Audit::Conversation) + + expect do + conversation.destroy! + end.to change(Audited::Audit, :count).by(1) + + audit = Audited::Audit.last + expect(audit.auditable_type).to eq('Conversation') + expect(audit.action).to eq('destroy') + expect(audit.auditable_id).to eq(conversation.id) + end + + it 'does not create audit log for other actions by default' do + skip 'Enterprise audit module not available' unless defined?(Enterprise::Audit::Conversation) + + expect do + conversation.update!(priority: 'high') + end.not_to(change(Audited::Audit, :count)) + end + end +end diff --git a/spec/policies/conversation_policy_spec.rb b/spec/policies/conversation_policy_spec.rb new file mode 100644 index 000000000..ecc3134fc --- /dev/null +++ b/spec/policies/conversation_policy_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe ConversationPolicy, type: :policy do + subject { described_class } + + let(:account) { create(:account) } + let(:conversation) { create(:conversation, account: account) } + let(:administrator) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator_context) { { user: administrator, account: account, account_user: administrator.account_users.first } } + let(:agent_context) { { user: agent, account: account, account_user: agent.account_users.first } } + + permissions :destroy? do + context 'when user is an administrator' do + it 'allows destroy' do + expect(subject).to permit(administrator_context, conversation) + end + end + + context 'when user is an agent' do + it 'denies destroy' do + expect(subject).not_to permit(agent_context, conversation) + end + end + end + + permissions :index? do + context 'when user is authenticated' do + it 'allows index' do + expect(subject).to permit(agent_context, conversation) + end + end + end +end From 8bc00f707be24a43394d7ff28afd81bca7ab4bdc Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 5 Jun 2025 18:29:37 -0500 Subject: [PATCH 06/11] feat(ee): Add transcription support for audio messages (#11670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2025-06-03 at 4 25 37 PM Fixes https://github.com/chatwoot/chatwoot/issues/10182 --------- Co-authored-by: Sojan Jose --- app/controllers/api/v1/accounts_controller.rb | 2 +- .../components-next/message/chips/Audio.vue | 91 ++++++++++--------- .../i18n/locale/en/generalSettings.json | 26 ++++++ .../dashboard/settings/account/Index.vue | 3 + .../account/components/AudioTranscription.vue | 51 +++++++++++ app/models/account.rb | 4 +- app/models/attachment.rb | 31 ++++++- app/models/message.rb | 8 +- .../conversation/response_builder_job.rb | 18 +++- .../jobs/messages/audio_transcription_job.rb | 13 +++ .../models/enterprise/concerns/attachment.rb | 15 +++ .../messages/audio_transcription_service.rb | 67 ++++++++++++++ .../messages/audio_transcription_job_spec.rb | 41 +++++++++ .../audio_transcription_service_spec.rb | 70 ++++++++++++++ 14 files changed, 389 insertions(+), 51 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/account/components/AudioTranscription.vue create mode 100644 enterprise/app/jobs/messages/audio_transcription_job.rb create mode 100644 enterprise/app/models/enterprise/concerns/attachment.rb create mode 100644 enterprise/app/services/messages/audio_transcription_service.rb create mode 100644 spec/enterprise/jobs/messages/audio_transcription_job_spec.rb create mode 100644 spec/enterprise/services/messages/audio_transcription_service_spec.rb diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 4d675593f..773126755 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController end def settings_params - params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label) + params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label) end def check_signup_enabled diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue index 680584048..431058463 100644 --- a/app/javascript/dashboard/components-next/message/chips/Audio.vue +++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue @@ -109,49 +109,58 @@ const downloadAudio = async () => {
- -
- {{ formatTime(currentTime) }} / {{ formatTime(duration) }} +
+ +
+ {{ formatTime(currentTime) }} / {{ formatTime(duration) }} +
+
+ +
+ + +
-
- + +
+ {{ attachment.transcribedText }}
- - -
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 07/11] 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 08/11] 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 09/11] 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 10/11] 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 From 3e73c1b4bc0eb5a5bae56d26dbc6dce083697279 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 10 Jun 2025 00:53:04 +0530 Subject: [PATCH 11/11] feat: Add RTL support in public help center (#11692) # Pull Request Template ## Description This PR adds RTL support in public help center. Fixes https://linear.app/chatwoot/issue/CW-4459/support-for-rtl-in-public-help-center ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Loom video https://www.loom.com/share/d48a26ec80e04545addca825882b4d79?sid=aa7a6b37-33bc-4f63-b1cc-54b27a7733cf ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- app/javascript/portal/application.scss | 17 ++++++++++++++++- .../portal/components/TableOfContents.vue | 12 +++++++----- app/javascript/portal/portalHelpers.js | 10 ++++++++++ .../public/api/v1/portals/_authors.html.erb | 8 ++++---- .../public/api/v1/portals/_header.html.erb | 6 +++--- 5 files changed, 40 insertions(+), 13 deletions(-) diff --git a/app/javascript/portal/application.scss b/app/javascript/portal/application.scss index bdb7bd1d4..4aaf36191 100644 --- a/app/javascript/portal/application.scss +++ b/app/javascript/portal/application.scss @@ -1,3 +1,4 @@ +// scss-lint:disable SpaceAfterPropertyColon @import 'tailwindcss/base'; @import 'tailwindcss/components'; @import 'tailwindcss/utilities'; @@ -7,7 +8,21 @@ html, body { - font-family: 'InterDisplay', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; + font-family: + 'InterDisplay', + -apple-system, + system-ui, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + Tahoma, + Arial, + sans-serif, + 'Noto Sans', + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Noto Color Emoji'; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; height: 100%; diff --git a/app/javascript/portal/components/TableOfContents.vue b/app/javascript/portal/components/TableOfContents.vue index ec7ccd895..3cb140018 100644 --- a/app/javascript/portal/components/TableOfContents.vue +++ b/app/javascript/portal/components/TableOfContents.vue @@ -37,7 +37,7 @@ export default { } if (el.tag === 'h2') { if (this.h1Count > 0) { - return 'ml-2'; + return 'ltr:ml-2 rtl:mr-2'; } return ''; } @@ -46,7 +46,7 @@ export default { if (!this.h1Count && !this.h2Count) { return ''; } - return 'ml-5'; + return 'ltr:ml-5 rtl:mr-5'; } return ''; @@ -94,17 +94,19 @@ export default {