Compare commits

..
31 changed files with 107 additions and 902 deletions
@@ -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, :audio_transcriptions, :auto_resolve_label)
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label)
end
def check_signup_enabled
@@ -71,7 +71,6 @@ 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,
@@ -109,58 +109,49 @@ const downloadAudio = async () => {
</audio>
<div
v-bind="$attrs"
class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
class="rounded-xl w-full gap-1 p-1.5 bg-n-alpha-white flex items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
>
<div class="flex gap-1 w-full flex-1 items-center justify-start">
<button class="p-0 border-0 size-8" @click="playOrPause">
<Icon
v-if="isPlaying"
class="size-8"
icon="i-teenyicons-pause-small-solid"
/>
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
</button>
<div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
<button class="p-0 border-0 size-8" @click="playOrPause">
<Icon
v-if="isPlaying"
class="size-8"
icon="i-teenyicons-pause-small-solid"
/>
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
</button>
<div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div
v-if="attachment.transcribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
{{ attachment.transcribedText }}
</div>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div>
</template>
@@ -92,32 +92,6 @@
"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."
@@ -16,7 +16,6 @@ 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 {
@@ -27,7 +26,6 @@ export default {
BuildInfo,
AccountDelete,
AutoResolve,
AudioTranscription,
SectionLayout,
WithLabel,
NextInput,
@@ -237,7 +235,6 @@ export default {
<woot-loading-state v-if="uiFlags.isFetchingItem" />
</div>
<AutoResolve v-if="showAutoResolutionConfig" />
<AudioTranscription v-if="isOnChatwootCloud" />
<AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<AccountDelete />
@@ -1,51 +0,0 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import Switch from 'next/switch/Switch.vue';
const { t } = useI18n();
const isEnabled = ref(false);
const { currentAccount, updateAccount } = useAccount();
watch(
currentAccount,
() => {
const { audio_transcriptions } = currentAccount.value?.settings || {};
isEnabled.value = !!audio_transcriptions;
},
{ deep: true, immediate: true }
);
const updateAccountSettings = async settings => {
try {
await updateAccount(settings);
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.ERROR'));
}
};
const toggleAudioTranscription = async () => {
return updateAccountSettings({
audio_transcriptions: isEnabled.value,
});
};
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.NOTE')"
with-border
>
<template #headerActions>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAudioTranscription" />
</div>
</template>
</SectionLayout>
</template>
@@ -47,7 +47,6 @@
* 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
@@ -158,20 +157,6 @@ 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
@@ -210,10 +195,10 @@ const matchesCondition = (conversationValue, filter) => {
return false; // We already handled null/undefined above
case 'is_greater_than':
return compareDates(conversationValue, filterValue, (a, b) => a > b);
return new Date(conversationValue) > new Date(filterValue);
case 'is_less_than':
return compareDates(conversationValue, filterValue, (a, b) => a < b);
return new Date(conversationValue) < new Date(filterValue);
case 'days_before': {
const today = new Date();
@@ -362,7 +347,6 @@ export const matchesFilters = (conversation, filters) => {
conversation,
filters[0].attribute_key
);
return matchesCondition(value, filters[0]);
}
@@ -463,241 +463,6 @@ 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
@@ -4,8 +4,7 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
@agent = agent
@conversation = conversation
inbox_name = @conversation.inbox&.sanitized_name
subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{inbox_name}."
subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{@conversation.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
+1 -1
View File
@@ -104,7 +104,7 @@ class ConversationReplyMailer < ApplicationMailer
end
def business_name
@inbox.business_name || @inbox.sanitized_name
@inbox.business_name || @inbox.name
end
def from_email
+1 -3
View File
@@ -36,7 +36,6 @@ 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': [],
@@ -53,8 +52,7 @@ 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
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
-13
View File
@@ -81,22 +81,11 @@ class Attachment < ApplicationRecord
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,
@@ -170,5 +159,3 @@ class Attachment < ApplicationRecord
file_content_type.start_with?('image/', 'video/', 'audio/')
end
end
Attachment.include_mod_with('Concerns::Attachment')
+2 -26
View File
@@ -47,6 +47,8 @@ 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 }
@@ -97,16 +99,6 @@ 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
@@ -186,22 +178,6 @@ 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?
+2 -6
View File
@@ -224,11 +224,6 @@ 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
@@ -318,7 +313,8 @@ class Message < ApplicationRecord
# we want to skip the update event if the message is not updated
return if previous_changes.blank?
send_update_event
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
previous_changes: previous_changes)
end
def send_reply
+1 -1
View File
@@ -12,7 +12,7 @@ class ContactPolicy < ApplicationPolicy
end
def export?
@account_user.administrator? || @account_user.agent?
@account_user.administrator?
end
def search?
+1 -15
View File
@@ -84,7 +84,6 @@ dialogflow:
{
'project_id': { 'type': 'string' },
'credentials': { 'type': 'object' },
'region': { 'type': 'string' },
},
'required': ['project_id', 'credentials'],
'additionalProperties': false,
@@ -107,21 +106,8 @@ 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', 'region']
visible_properties: ['project_id']
google_translate:
id: google_translate
logo: google-translate.png
@@ -1,6 +1,5 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
MAX_MESSAGE_LENGTH = 10_000
retry_on ActiveStorage::FileNotFoundError, attempts: 3
def perform(conversation, assistant)
@conversation = conversation
@@ -13,8 +12,6 @@ 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
@@ -52,24 +49,8 @@ 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?
audio_transcriptions = extract_audio_transcriptions(message.attachments)
return audio_transcriptions if audio_transcriptions.present?
'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
message.attachments.any? ? 'User has shared an attachment' : 'User has shared a message without content'
end
def determine_role(message)
@@ -1,12 +0,0 @@
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
end
end
@@ -1,15 +0,0 @@
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
@@ -1,61 +0,0 @@
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?
transcriptions = transcribe_audio
Rails.logger.info "Audio transcription successful: #{transcriptions}"
{ success: true, transcriptions: transcriptions }
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
@@ -65,37 +65,13 @@ 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 = build_session_path(session_id)
session = "projects/#{hook.settings['project_id']}/agent/sessions/#{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
+1 -1
View File
@@ -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.46",
"@chatwoot/utils": "^0.0.45",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
+5 -5
View File
@@ -23,8 +23,8 @@ importers:
specifier: 1.1.1-next
version: 1.1.1-next
'@chatwoot/utils':
specifier: ^0.0.46
version: 0.0.46
specifier: ^0.0.45
version: 0.0.45
'@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.46':
resolution: {integrity: sha512-a68CQ+aPFfyMr7dnXUUSt/kwHEazBd7Y8aidDZeDp5eL7sych7EpmT5XMTmhttlqMiRsmwETblXJJ2fBH6I44A==}
'@chatwoot/utils@0.0.45':
resolution: {integrity: sha512-zqmuri6MrEFAY1tLv7Z3HBy4Ig60LhSrLkEiHegVsOVSxPv4Bedq+xmAW7LphvcLNgbkkvu17MU91gvMVlpEHw==}
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.46':
'@chatwoot/utils@0.0.45':
dependencies:
date-fns: 2.30.0
@@ -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' }, region: 'europe-west1' } } }
let(:params) { { app_id: 'dialogflow', inbox_id: inbox.id, settings: { project_id: 'xx', credentials: { test: 'test' } } } }
describe 'POST /api/v1/accounts/{account.id}/integrations/hooks' do
context 'when it is an unauthenticated user' do
@@ -1,41 +0,0 @@
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
@@ -1,57 +0,0 @@
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
end
end
+1 -1
View File
@@ -9,7 +9,7 @@ FactoryBot.define do
trait :dialogflow do
app_id { 'dialogflow' }
settings { { project_id: 'test', credentials: {}, region: 'global' } }
settings { { project_id: 'test', credentials: {} } }
end
trait :dyte do
@@ -175,65 +175,4 @@ 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
@@ -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&.sanitized_name}.")
.display_id}] has been created in #{conversation.inbox&.name}.")
end
it 'renders the receiver email' do
+18 -18
View File
@@ -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 #{message.conversation.inbox.sanitized_name}"])
expect(mail[:from].display_names).to eq(["#{message.sender.available_name} from Inbox"])
end
end
@@ -224,11 +224,11 @@ RSpec.describe ConversationReplyMailer do
end
context 'when smtp enabled for email channel' do
let(:smtp_channel) do
let(:smtp_email_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_channel.inbox, account: account).reload }
let(:conversation) { create(:conversation, assignee: agent, inbox: smtp_email_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_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
expect(mail['from'].value).to eq "#{message.sender.available_name} from #{smtp_email_channel.inbox.name} <#{smtp_email_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_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
expect(mail['from'].value).to eq "#{message.sender.available_name} from #{smtp_email_channel.inbox.name} <#{smtp_email_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_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
expect(mail['from'].value).to eq "#{conversation.assignee.available_name} from #{smtp_email_channel.inbox.name} <#{smtp_email_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_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
expect(mail['from'].value).to eq "Notifications from #{smtp_email_channel.inbox.name} <#{smtp_email_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.sanitized_name} <#{smtp_channel.email}>"
expect(mail['from'].value).to eq "Notifications from #{conversation.inbox.name} <#{smtp_email_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_channel.email}>"
"Notifications from #{conversation.inbox.business_name} <#{smtp_email_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_channel.email}>"
expect(mail['from'].value).to eq "#{agent.available_name} from #{conversation.inbox.business_name} <#{smtp_email_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_channel.email}>"
expect(mail['from'].value).to eq "#{agent_2.available_name} from #{conversation.inbox.business_name} <#{smtp_email_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.sanitized_name} <#{smtp_channel.email}>"
expect(mail['from'].value).to eq "#{conversation.inbox.name} <#{smtp_email_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_channel.email}>"
expect(mail['from'].value).to eq "#{conversation.inbox.business_name} <#{smtp_email_channel.email}>"
end
end
end
context 'when smtp enabled for microsoft email channel' do
let(:ms_smtp_channel) do
let(:ms_smtp_email_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_channel.inbox, account: account).reload }
let(:conversation) { create(:conversation, assignee: agent, inbox: ms_smtp_email_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_channel) do
let(:ms_smtp_email_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_channel.inbox, account: account).reload }
let(:conversation) { create(:conversation, assignee: agent, inbox: ms_smtp_email_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.sanitized_name} <#{reply_to_email}>"
reply_to = "#{message.sender.available_name} from #{conversation.inbox.name} <#{reply_to_email}>"
expect(mail['REPLY-TO'].value).to eq(reply_to)
expect(mail.reply_to).to eq([reply_to_email])
end
+25 -131
View File
@@ -164,7 +164,31 @@ RSpec.describe Inbox do
let(:inbox) { FactoryBot.create(:inbox) }
context 'when validating inbox name' do
it 'does not allow empty string' 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
inbox.name = ''
expect(inbox).not_to be_valid
expect(inbox.errors.full_messages[0]).to eq(
@@ -258,134 +282,4 @@ 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<Bad>@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