diff --git a/Gemfile.lock b/Gemfile.lock
index a1d8c079c..ca4871a87 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -174,6 +174,8 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
+ childprocess (5.1.0)
+ logger (~> 1.5)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
@@ -436,10 +438,12 @@ GEM
json (>= 1.8)
rexml
language_server-protocol (3.17.0.5)
- launchy (2.5.2)
+ launchy (3.1.1)
addressable (~> 2.8)
- letter_opener (1.8.1)
- launchy (>= 2.2, < 3)
+ childprocess (~> 5.0)
+ logger (~> 1.6)
+ letter_opener (1.10.0)
+ launchy (>= 2.2, < 4)
line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0)
@@ -572,7 +576,7 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
- public_suffix (6.0.0)
+ public_suffix (6.0.2)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index 61d16b2ca..e7b3b197b 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -81,11 +81,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def create_channel
- return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
+ return unless allowed_channel_types.include?(permitted_params[:channel][:type])
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end
+ def allowed_channel_types
+ %w[web_widget api email line telegram whatsapp sms]
+ end
+
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js
index 9c0a24925..36dd6216e 100644
--- a/app/javascript/dashboard/components-next/icon/provider.js
+++ b/app/javascript/dashboard/components-next/icon/provider.js
@@ -13,6 +13,7 @@ export function useChannelIcon(inbox) {
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
+ 'Channel::Voice': 'i-ri-phone-fill',
};
const providerIconMap = {
diff --git a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
index df30d7138..5860e30ea 100644
--- a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
+++ b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
@@ -19,6 +19,12 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-ri-whatsapp-fill');
});
+ it('returns correct icon for Voice channel', () => {
+ const inbox = { channel_type: 'Channel::Voice' };
+ const { value: icon } = useChannelIcon(inbox);
+ expect(icon).toBe('i-ri-phone-fill');
+ });
+
describe('Email channel', () => {
it('returns mail icon for generic email channel', () => {
const inbox = { channel_type: 'Channel::Email' };
diff --git a/app/javascript/dashboard/components-next/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue
index ea6eb0417..ed6d7a20b 100644
--- a/app/javascript/dashboard/components-next/input/Input.vue
+++ b/app/javascript/dashboard/components-next/input/Input.vue
@@ -1,51 +1,21 @@
+
+
+
Attachments:
diff --git a/config/features.yml b/config/features.yml index 131456c72..95f7e33d4 100644 --- a/config/features.yml +++ b/config/features.yml @@ -168,4 +168,8 @@ enabled: true - name: crm_integration display_name: CRM Integration - enabled: false \ No newline at end of file + enabled: false +- name: channel_voice + display_name: Voice Channel + enabled: false + chatwoot_internal: true diff --git a/db/migrate/20250620120000_create_channel_voice.rb b/db/migrate/20250620120000_create_channel_voice.rb new file mode 100644 index 000000000..9e2a25723 --- /dev/null +++ b/db/migrate/20250620120000_create_channel_voice.rb @@ -0,0 +1,16 @@ +class CreateChannelVoice < ActiveRecord::Migration[7.0] + def change + create_table :channel_voice do |t| + t.string :phone_number, null: false + t.string :provider, null: false, default: 'twilio' + t.jsonb :provider_config, null: false + t.integer :account_id, null: false + t.jsonb :additional_attributes, default: {} + + t.timestamps + end + + add_index :channel_voice, :phone_number, unique: true + add_index :channel_voice, :account_id + end +end \ No newline at end of file diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb index e5a055836..ec8e8e653 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb @@ -25,8 +25,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base def playground response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response( - params[:message_content], - message_history + additional_message: params[:message_content], + message_history: message_history ) render json: response diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb index b39db609d..396c3a91d 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb @@ -6,4 +6,28 @@ module Enterprise::Api::V1::Accounts::InboxesController def ee_inbox_attributes [auto_assignment_config: [:max_assignment_limit]] end + + private + + def allowed_channel_types + super + ['voice'] + end + + def channel_type_from_params + case permitted_params[:channel][:type] + when 'voice' + Channel::Voice + else + super + end + end + + def account_channels_method + case permitted_params[:channel][:type] + when 'voice' + Current.account.voice_channels + else + super + end + end end diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index f341a6e98..431945896 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -26,8 +26,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob def generate_and_process_response @response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response( - @conversation.messages.incoming.last.content, - collect_previous_messages + message_history: collect_previous_messages ) return process_action('handoff') if handoff_requested? @@ -43,33 +42,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob .where(message_type: [:incoming, :outgoing]) .where(private: false) .map do |message| - { - content: message_content(message), - role: determine_role(message) - } - end - end - - 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] + { + content: prepare_multimodal_message_content(message), + role: determine_role(message) + } end - transcriptions end def determine_role(message) @@ -78,6 +55,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob message.message_type == 'incoming' ? 'user' : 'system' end + def prepare_multimodal_message_content(message) + Captain::OpenAiMessageBuilderService.new(message: message).generate_content + end + def handoff_requested? @response['response'] == 'conversation_handoff' end diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb new file mode 100644 index 000000000..a313129e2 --- /dev/null +++ b/enterprise/app/models/channel/voice.rb @@ -0,0 +1,64 @@ +# == Schema Information +# +# Table name: channel_voice +# +# id :bigint not null, primary key +# additional_attributes :jsonb +# phone_number :string not null +# provider :string default("twilio"), not null +# provider_config :jsonb not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null +# +# Indexes +# +# index_channel_voice_on_account_id (account_id) +# index_channel_voice_on_phone_number (phone_number) UNIQUE +# +class Channel::Voice < ApplicationRecord + include Channelable + + self.table_name = 'channel_voice' + + validates :phone_number, presence: true, uniqueness: true + validates :provider, presence: true + validates :provider_config, presence: true + + # Validate phone number format (E.164 format) + validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ } + + # Provider-specific configs stored in JSON + validate :validate_provider_config + + EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze + + def name + "Voice (#{phone_number})" + end + + def messaging_window_enabled? + false + end + + private + + def validate_provider_config + return if provider_config.blank? + + case provider + when 'twilio' + validate_twilio_config + end + end + + def validate_twilio_config + config = provider_config.with_indifferent_access + required_keys = %w[account_sid auth_token api_key_sid api_key_secret] + + required_keys.each do |key| + errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank? + end + end +end + diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 4a573a4c4..c31b6c10e 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -11,5 +11,6 @@ module Enterprise::Concerns::Account has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document' has_many :copilot_threads, dependent: :destroy_async + has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice' end end diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb index 569931d44..ca8fafaa0 100644 --- a/enterprise/app/services/captain/llm/assistant_chat_service.rb +++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb @@ -12,9 +12,16 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService register_tools end - def generate_response(input, previous_messages = [], role = 'user') - @messages += previous_messages - @messages << { role: role, content: input } if input.present? + # additional_message: A single message (String) from the user that should be appended to the chat. + # It can be an empty String or nil when you only want to supply historical messages. + # message_history: An Array of already formatted messages that provide the previous context. + # role: The role for the additional_message (defaults to `user`). + # + # NOTE: Parameters are provided as keyword arguments to improve clarity and avoid relying on + # positional ordering. + def generate_response(additional_message: nil, message_history: [], role: 'user') + @messages += message_history + @messages << { role: role, content: additional_message } if additional_message.present? request_chat_completion end diff --git a/enterprise/app/services/captain/open_ai_message_builder_service.rb b/enterprise/app/services/captain/open_ai_message_builder_service.rb new file mode 100644 index 000000000..3320ad537 --- /dev/null +++ b/enterprise/app/services/captain/open_ai_message_builder_service.rb @@ -0,0 +1,59 @@ +class Captain::OpenAiMessageBuilderService + pattr_initialize [:message!] + + def generate_content + parts = [] + parts << text_part(@message.content) if @message.content.present? + parts.concat(attachment_parts(@message.attachments)) if @message.attachments.any? + + return 'Message without content' if parts.blank? + return parts.first[:text] if parts.one? && parts.first[:type] == 'text' + + parts + end + + private + + def text_part(text) + { type: 'text', text: text } + end + + def image_part(image_url) + { type: 'image_url', image_url: { url: image_url } } + end + + def attachment_parts(attachments) + image_attachments = attachments.where(file_type: :image) + image_content = image_parts(image_attachments) + + transcription = extract_audio_transcriptions(attachments) + transcription_part = text_part(transcription) if transcription.present? + + attachment_part = text_part('User has shared an attachment') if attachments.where.not(file_type: %i[image audio]).exists? + + [image_content, transcription_part, attachment_part].flatten.compact + end + + def image_parts(image_attachments) + image_attachments.each_with_object([]) do |attachment, parts| + url = get_attachment_url(attachment) + parts << image_part(url) if url.present? + end + end + + def get_attachment_url(attachment) + return attachment.external_url if attachment.external_url.present? + + attachment.file.attached? ? attachment.file_url : nil + end + + def extract_audio_transcriptions(attachments) + audio_attachments = attachments.where(file_type: :audio) + return '' if audio_attachments.blank? + + audio_attachments.map do |attachment| + result = Messages::AudioTranscriptionService.new(attachment).perform + result[:success] ? result[:transcriptions] : '' + end.join + end +end \ No newline at end of file diff --git a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb index 76c09a27a..e4df1050b 100644 --- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb +++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb @@ -4,6 +4,8 @@ class Enterprise::Billing::CreateStripeCustomerService DEFAULT_QUANTITY = 2 def perform + return if existing_subscription? + customer_id = prepare_customer_id subscription = Stripe::Subscription.create( { @@ -50,4 +52,18 @@ class Enterprise::Billing::CreateStripeCustomerService price_ids = default_plan['price_ids'] price_ids.first end + + def existing_subscription? + stripe_customer_id = account.custom_attributes['stripe_customer_id'] + return false if stripe_customer_id.blank? + + subscriptions = Stripe::Subscription.list( + { + customer: stripe_customer_id, + status: 'active', + limit: 1 + } + ) + subscriptions.data.present? + end end diff --git a/public/assets/images/dashboard/channels/voice.png b/public/assets/images/dashboard/channels/voice.png new file mode 100644 index 000000000..7c9481faf Binary files /dev/null and b/public/assets/images/dashboard/channels/voice.png differ diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb index 1f6d83d80..80be6f30f 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb @@ -211,8 +211,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do expect(response).to have_http_status(:success) expect(chat_service).to have_received(:generate_response).with( - valid_params[:message_content], - valid_params[:message_history] + additional_message: valid_params[:message_content], + message_history: valid_params[:message_history] ) expect(json_response[:content]).to eq('Assistant response') end @@ -232,8 +232,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do expect(response).to have_http_status(:success) expect(chat_service).to have_received(:generate_response).with( - params_without_history[:message_content], - [] + additional_message: params_without_history[:message_content], + message_history: [] ) end end diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb index 724a7b0cb..498c42abd 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb @@ -22,6 +22,22 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do expect(response).to have_http_status(:success) expect(JSON.parse(response.body)['auto_assignment_config']['max_assignment_limit']).to eq 10 end + + it 'creates a voice inbox when administrator' do + post "/api/v1/accounts/#{account.id}/inboxes", + headers: admin.create_new_auth_token, + params: { name: 'Voice Inbox', + channel: { type: 'voice', phone_number: '+15551234567', + provider_config: { account_sid: "AC#{SecureRandom.hex(16)}", + auth_token: SecureRandom.hex(16), + api_key_sid: SecureRandom.hex(8), + api_key_secret: SecureRandom.hex(16) } } }, + as: :json + + expect(response).to have_http_status(:success) + expect(response.body).to include('Voice Inbox') + expect(response.body).to include('+15551234567') + end end end diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb index 1e4a6e824..ca8d4a6c0 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -30,5 +30,30 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do account.reload expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1) end + + context 'when message contains an image' do + let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') } + let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') } + + before do + image_attachment + end + + it 'includes image URL directly in the message content for OpenAI vision analysis' do + # Expect the generate_response to receive multimodal content with image URL + expect(mock_llm_chat_service).to receive(:generate_response) do |**kwargs| + history = kwargs[:message_history] + last_entry = history.last + expect(last_entry[:content]).to be_an(Array) + expect(last_entry[:content].any? { |part| part[:type] == 'text' && part[:text] == 'Can you help with this error?' }).to be true + expect(last_entry[:content].any? do |part| + part[:type] == 'image_url' && part[:image_url][:url] == 'https://example.com/error.jpg' + end).to be true + { 'response' => 'I can see the error in your image. It appears to be a database connection issue.' } + end + + described_class.perform_now(conversation, assistant) + end + end end end diff --git a/spec/enterprise/models/channel/voice_spec.rb b/spec/enterprise/models/channel/voice_spec.rb new file mode 100644 index 000000000..2e52807b0 --- /dev/null +++ b/spec/enterprise/models/channel/voice_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Channel::Voice do + let(:channel) { create(:channel_voice) } + + it 'has a valid factory' do + expect(channel).to be_valid + end + + describe 'validations' do + it 'validates presence of provider_config' do + channel.provider_config = nil + expect(channel).not_to be_valid + expect(channel.errors[:provider_config]).to include("can't be blank") + end + + it 'validates presence of account_sid in provider_config' do + channel.provider_config = { auth_token: 'token' } + expect(channel).not_to be_valid + expect(channel.errors[:provider_config]).to include('account_sid is required for Twilio provider') + end + + it 'validates presence of auth_token in provider_config' do + channel.provider_config = { account_sid: 'sid' } + expect(channel).not_to be_valid + expect(channel.errors[:provider_config]).to include('auth_token is required for Twilio provider') + end + + it 'validates presence of api_key_sid in provider_config' do + channel.provider_config = { account_sid: 'sid', auth_token: 'token' } + expect(channel).not_to be_valid + expect(channel.errors[:provider_config]).to include('api_key_sid is required for Twilio provider') + end + + it 'validates presence of api_key_secret in provider_config' do + channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key' } + expect(channel).not_to be_valid + expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider') + end + + it 'is valid with all required provider_config fields' do + channel.provider_config = { + account_sid: 'test_sid', + auth_token: 'test_token', + api_key_sid: 'test_key', + api_key_secret: 'test_secret' + } + expect(channel).to be_valid + end + end + + describe '#name' do + it 'returns Voice with phone number' do + expect(channel.name).to include('Voice') + expect(channel.name).to include(channel.phone_number) + end + end +end diff --git a/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb b/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb new file mode 100644 index 000000000..13c29f756 --- /dev/null +++ b/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb @@ -0,0 +1,309 @@ +require 'rails_helper' + +RSpec.describe Captain::OpenAiMessageBuilderService do + subject(:service) { described_class.new(message: message) } + + let(:message) { create(:message, content: 'Hello world') } + + describe '#generate_content' do + context 'when message has only text content' do + it 'returns the text content directly' do + expect(service.generate_content).to eq('Hello world') + end + end + + context 'when message has no content and no attachments' do + let(:message) { create(:message, content: nil) } + + it 'returns default message' do + expect(service.generate_content).to eq('Message without content') + end + end + + context 'when message has text content and attachments' do + before do + attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg') + attachment.save! + end + + it 'returns an array of content parts' do + result = service.generate_content + expect(result).to be_an(Array) + expect(result).to include({ type: 'text', text: 'Hello world' }) + expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }) + end + end + + context 'when message has only non-text attachments' do + let(:message) { create(:message, content: nil) } + + before do + attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg') + attachment.save! + end + + it 'returns an array of content parts without text' do + result = service.generate_content + expect(result).to be_an(Array) + expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }) + expect(result).not_to include(hash_including(type: 'text', text: 'Hello world')) + end + end + end + + describe '#attachment_parts' do + let(:message) { create(:message, content: nil) } + let(:attachments) { message.attachments } + + context 'with image attachments' do + before do + attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg') + attachment.save! + end + + it 'includes image parts' do + result = service.send(:attachment_parts, attachments) + expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }) + end + end + + context 'with audio attachments' do + let(:audio_attachment) do + attachment = message.attachments.build(account_id: message.account_id, file_type: :audio) + attachment.save! + attachment + end + + before do + allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return( + instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio transcription text' }) + ) + end + + it 'includes transcription text part' do + audio_attachment # trigger creation + result = service.send(:attachment_parts, attachments) + expect(result).to include({ type: 'text', text: 'Audio transcription text' }) + end + end + + context 'with other file types' do + before do + attachment = message.attachments.build(account_id: message.account_id, file_type: :file) + attachment.save! + end + + it 'includes generic attachment message' do + result = service.send(:attachment_parts, attachments) + expect(result).to include({ type: 'text', text: 'User has shared an attachment' }) + end + end + + context 'with mixed attachment types' do + let(:image_attachment) do + attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg') + attachment.save! + attachment + end + + let(:audio_attachment) do + attachment = message.attachments.build(account_id: message.account_id, file_type: :audio) + attachment.save! + attachment + end + + let(:document_attachment) do + attachment = message.attachments.build(account_id: message.account_id, file_type: :file) + attachment.save! + attachment + end + + before do + allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return( + instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio text' }) + ) + end + + it 'includes all relevant parts' do + image_attachment # trigger creation + audio_attachment # trigger creation + document_attachment # trigger creation + + result = service.send(:attachment_parts, attachments) + expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }) + expect(result).to include({ type: 'text', text: 'Audio text' }) + expect(result).to include({ type: 'text', text: 'User has shared an attachment' }) + end + end + end + + describe '#image_parts' do + let(:message) { create(:message, content: nil) } + + context 'with valid image attachments' do + let(:image1) do + attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image1.jpg') + attachment.save! + attachment + end + + let(:image2) do + attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image2.jpg') + attachment.save! + attachment + end + + it 'returns image parts for all valid images' do + image1 # trigger creation + image2 # trigger creation + + image_attachments = message.attachments.where(file_type: :image) + result = service.send(:image_parts, image_attachments) + + expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image1.jpg' } }) + expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image2.jpg' } }) + end + end + + context 'with image attachments without URLs' do + let(:image_attachment) do + attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: nil) + attachment.save! + attachment + end + + before do + allow(image_attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false)) + end + + it 'skips images without valid URLs' do + image_attachment # trigger creation + + image_attachments = message.attachments.where(file_type: :image) + result = service.send(:image_parts, image_attachments) + + expect(result).to be_empty + end + end + end + + describe '#get_attachment_url' do + let(:attachment) do + attachment = message.attachments.build(account_id: message.account_id, file_type: :image) + attachment.save! + attachment + end + + context 'when attachment has external_url' do + before { attachment.update(external_url: 'https://example.com/image.jpg') } + + it 'returns external_url' do + expect(service.send(:get_attachment_url, attachment)).to eq('https://example.com/image.jpg') + end + end + + context 'when attachment has attached file' do + before do + attachment.update(external_url: nil) + allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: true)) + allow(attachment).to receive(:file_url).and_return('https://local.com/file.jpg') + end + + it 'returns file_url' do + expect(service.send(:get_attachment_url, attachment)).to eq('https://local.com/file.jpg') + end + end + + context 'when attachment has no URL or file' do + before do + attachment.update(external_url: nil) + allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false)) + end + + it 'returns nil' do + expect(service.send(:get_attachment_url, attachment)).to be_nil + end + end + end + + describe '#extract_audio_transcriptions' do + let(:message) { create(:message, content: nil) } + + context 'with no audio attachments' do + it 'returns empty string' do + result = service.send(:extract_audio_transcriptions, message.attachments) + expect(result).to eq('') + end + end + + context 'with successful audio transcriptions' do + let(:audio1) do + attachment = message.attachments.build(account_id: message.account_id, file_type: :audio) + attachment.save! + attachment + end + + let(:audio2) do + attachment = message.attachments.build(account_id: message.account_id, file_type: :audio) + attachment.save! + attachment + end + + before do + allow(Messages::AudioTranscriptionService).to receive(:new).with(audio1).and_return( + instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'First audio text. ' }) + ) + allow(Messages::AudioTranscriptionService).to receive(:new).with(audio2).and_return( + instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Second audio text.' }) + ) + end + + it 'concatenates all successful transcriptions' do + audio1 # trigger creation + audio2 # trigger creation + + attachments = message.attachments + result = service.send(:extract_audio_transcriptions, attachments) + expect(result).to eq('First audio text. Second audio text.') + end + end + + context 'with failed audio transcriptions' do + let(:audio_attachment) do + attachment = message.attachments.build(account_id: message.account_id, file_type: :audio) + attachment.save! + attachment + end + + before do + allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return( + instance_double(Messages::AudioTranscriptionService, perform: { success: false, transcriptions: nil }) + ) + end + + it 'returns empty string for failed transcriptions' do + audio_attachment # trigger creation + + attachments = message.attachments + result = service.send(:extract_audio_transcriptions, attachments) + expect(result).to eq('') + end + end + end + + describe 'private helper methods' do + describe '#text_part' do + it 'returns correct text part format' do + result = service.send(:text_part, 'Hello world') + expect(result).to eq({ type: 'text', text: 'Hello world' }) + end + end + + describe '#image_part' do + it 'returns correct image part format' do + result = service.send(:image_part, 'https://example.com/image.jpg') + expect(result).to eq({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }) + end + end + end +end diff --git a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb index 95edf73a1..f5b0bbe86 100644 --- a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb @@ -6,6 +6,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do let(:account) { create(:account) } let!(:admin1) { create(:user, account: account, role: :administrator) } let(:admin2) { create(:user, account: account, role: :administrator) } + let(:subscriptions_list) { double } describe '#perform' do before do @@ -19,8 +20,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do it 'does not call stripe methods if customer id is present' do account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' }) - + allow(subscriptions_list).to receive(:data).and_return([]) allow(Stripe::Customer).to receive(:create) + allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list) allow(Stripe::Subscription).to receive(:create) .and_return( { @@ -78,4 +80,63 @@ describe Enterprise::Billing::CreateStripeCustomerService do ) end end + + describe 'when checking for existing subscriptions' do + before do + create( + :installation_config, + { name: 'CHATWOOT_CLOUD_PLANS', value: [ + { 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] } + ] } + ) + end + + context 'when account has no stripe_customer_id' do + it 'creates a new subscription' do + customer = double + allow(Stripe::Customer).to receive(:create).and_return(customer) + allow(customer).to receive(:id).and_return('cus_random_number') + allow(Stripe::Subscription).to receive(:create).and_return( + { + plan: { id: 'price_random_number', product: 'prod_random_number' }, + quantity: 2 + }.with_indifferent_access + ) + + create_stripe_customer_service.new(account: account).perform + + expect(Stripe::Customer).to have_received(:create) + expect(Stripe::Subscription).to have_received(:create) + end + end + + context 'when account has stripe_customer_id' do + let(:stripe_customer_id) { 'cus_random_number' } + + before do + account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id }) + end + + context 'when customer has active subscriptions' do + before do + allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list) + allow(subscriptions_list).to receive(:data).and_return(['subscription']) + allow(Stripe::Subscription).to receive(:create) + end + + it 'does not create a new subscription' do + create_stripe_customer_service.new(account: account).perform + + expect(Stripe::Subscription).not_to have_received(:create) + expect(Stripe::Subscription).to have_received(:list).with( + { + customer: stripe_customer_id, + status: 'active', + limit: 1 + } + ) + end + end + end + end end diff --git a/spec/factories/channel/channel_voice.rb b/spec/factories/channel/channel_voice.rb new file mode 100644 index 000000000..33be75f2e --- /dev/null +++ b/spec/factories/channel/channel_voice.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :channel_voice, class: 'Channel::Voice' do + sequence(:phone_number) { |n| "+155512345#{n.to_s.rjust(2, '0')}" } + provider_config do + { + account_sid: "AC#{SecureRandom.hex(16)}", + auth_token: SecureRandom.hex(16), + api_key_sid: SecureRandom.hex(8), + api_key_secret: SecureRandom.hex(16) + } + end + account + + after(:create) do |channel_voice| + create(:inbox, channel: channel_voice, account: channel_voice.account) + end + end +end diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb index 8485fcf7a..2a0d6c8b0 100644 --- a/spec/mailers/conversation_reply_mailer_spec.rb +++ b/spec/mailers/conversation_reply_mailer_spec.rb @@ -154,6 +154,27 @@ RSpec.describe ConversationReplyMailer do expect(mail.message_id).to eq message.source_id end + context 'when message is a CSAT survey' do + let(:csat_message) do + create(:message, conversation: conversation, account: account, message_type: 'template', + content_type: 'input_csat', content: 'How would you rate our support?', sender: agent) + end + + it 'includes CSAT survey URL in outgoing_content' do + with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do + mail = described_class.email_reply(csat_message).deliver_now + expect(mail.decoded).to include "https://app.chatwoot.com/survey/responses/#{conversation.uuid}" + end + end + + it 'uses outgoing_content for CSAT message body' do + with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do + mail = described_class.email_reply(csat_message).deliver_now + expect(mail.decoded).to include csat_message.outgoing_content + end + end + end + context 'with email attachments' do it 'includes small attachments as email attachments' do message_with_attachment = create(:message, conversation: conversation, account: account, message_type: 'outgoing', diff --git a/spec/presenters/message_content_presenter_spec.rb b/spec/presenters/message_content_presenter_spec.rb index b1be37ef2..f85bdb8d1 100644 --- a/spec/presenters/message_content_presenter_spec.rb +++ b/spec/presenters/message_content_presenter_spec.rb @@ -34,20 +34,21 @@ RSpec.describe MessageContentPresenter do before do allow(message.inbox).to receive(:web_widget?).and_return(false) - allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com') end it 'returns I18n default message when no CSAT config and dynamically generates survey URL' do - expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}" - allow(I18n).to receive(:t).with('conversations.survey.response', link: expected_url) - .and_return("Please rate this conversation, #{expected_url}") - expect(presenter.outgoing_content).to eq("Please rate this conversation, #{expected_url}") + with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do + expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}" + expect(presenter.outgoing_content).to include(expected_url) + end end it 'returns CSAT config message when config exists and dynamically generates survey URL' do - allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' }) - expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}" - expect(presenter.outgoing_content).to eq("Custom CSAT message #{expected_url}") + with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do + allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' }) + expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}" + expect(presenter.outgoing_content).to eq("Custom CSAT message #{expected_url}") + end end end end