diff --git a/app/controllers/api/v1/accounts/integrations/notion_controller.rb b/app/controllers/api/v1/accounts/integrations/notion_controller.rb new file mode 100644 index 000000000..dff6ccece --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/notion_controller.rb @@ -0,0 +1,14 @@ +class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController + before_action :fetch_hook, only: [:destroy] + + def destroy + @hook.destroy! + head :ok + end + + private + + def fetch_hook + @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion') + end +end \ No newline at end of file diff --git a/app/controllers/api/v1/accounts/notion/authorizations_controller.rb b/app/controllers/api/v1/accounts/notion/authorizations_controller.rb new file mode 100644 index 000000000..bb9b2f858 --- /dev/null +++ b/app/controllers/api/v1/accounts/notion/authorizations_controller.rb @@ -0,0 +1,21 @@ +class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController + include NotionConcern + + def create + redirect_url = notion_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/notion/callback", + response_type: 'code', + owner: 'user', + state: state, + client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil) + } + ) + + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end +end \ No newline at end of file diff --git a/app/controllers/concerns/notion_concern.rb b/app/controllers/concerns/notion_concern.rb new file mode 100644 index 000000000..2b94fe63b --- /dev/null +++ b/app/controllers/concerns/notion_concern.rb @@ -0,0 +1,21 @@ +module NotionConcern + extend ActiveSupport::Concern + + def notion_client + app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil) + app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil) + + ::OAuth2::Client.new(app_id, app_secret, { + site: 'https://api.notion.com', + authorize_url: 'https://api.notion.com/v1/oauth/authorize', + token_url: 'https://api.notion.com/v1/oauth/token', + auth_scheme: :basic_auth + }) + end + + private + + def scope + '' + end +end diff --git a/app/controllers/notion/callbacks_controller.rb b/app/controllers/notion/callbacks_controller.rb new file mode 100644 index 000000000..94030fc8e --- /dev/null +++ b/app/controllers/notion/callbacks_controller.rb @@ -0,0 +1,36 @@ +class Notion::CallbacksController < OauthCallbackController + include NotionConcern + + private + + def provider_name + 'notion' + end + + def oauth_client + notion_client + end + + def handle_response + hook = account.hooks.new( + access_token: parsed_body['access_token'], + status: 'enabled', + app_id: 'notion', + settings: { + token_type: parsed_body['token_type'], + workspace_name: parsed_body['workspace_name'], + workspace_id: parsed_body['workspace_id'], + workspace_icon: parsed_body['workspace_icon'], + bot_id: parsed_body['bot_id'], + owner: parsed_body['owner'] + } + ) + + hook.save! + redirect_to notion_redirect_uri + end + + def notion_redirect_uri + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion" + end +end \ No newline at end of file diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 204bfc95b..771f9f28c 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -39,6 +39,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], + 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] } diff --git a/app/javascript/dashboard/api/notion_auth.js b/app/javascript/dashboard/api/notion_auth.js new file mode 100644 index 000000000..8a0027f9b --- /dev/null +++ b/app/javascript/dashboard/api/notion_auth.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class NotionOAuthClient extends ApiClient { + constructor() { + super('notion', { accountScoped: true }); + } + + generateAuthorization() { + return axios.post(`${this.url}/authorization`); + } +} + +export default new NotionOAuthClient(); diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 41f63d0a2..b3e091722 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -328,6 +328,14 @@ "DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.", "BUTTON_TEXT": "Connect Linear workspace" } + }, + "NOTION": { + "DELETE": { + "TITLE": "Are you sure you want to delete the Notion integration?", + "MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.", + "CONFIRM": "Yes, delete", + "CANCEL": "Cancel" + } } }, "CAPTAIN": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Notion.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Notion.vue new file mode 100644 index 000000000..c2d63ad22 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Notion.vue @@ -0,0 +1,80 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js index e50eccb3a..bb700fc74 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js @@ -8,6 +8,7 @@ import DashboardApps from './DashboardApps/Index.vue'; import Slack from './Slack.vue'; import SettingsContent from '../Wrapper.vue'; import Linear from './Linear.vue'; +import Notion from './Notion.vue'; import Shopify from './Shopify.vue'; export default { @@ -90,6 +91,15 @@ export default { }, props: route => ({ code: route.query.code }), }, + { + path: 'notion', + name: 'settings_integrations_notion', + component: Notion, + meta: { + permissions: ['administrator'], + }, + props: route => ({ code: route.query.code }), + }, { path: 'shopify', name: 'settings_integrations_shopify', diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb index dfe889bfa..3b5cd821a 100644 --- a/app/models/integrations/app.rb +++ b/app/models/integrations/app.rb @@ -55,9 +55,11 @@ class Integrations::App when 'linear' GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present? when 'shopify' - account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present? + shopify_enabled?(account) when 'leadsquared' account.feature_enabled?('crm_integration') + when 'notion' + notion_enabled?(account) else true end @@ -113,4 +115,14 @@ class Integrations::App all.detect { |app| app.id == params[:id] } end end + + private + + def shopify_enabled?(account) + account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present? + end + + def notion_enabled?(account) + account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present? + end end diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index e7300b525..ca77fa13d 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -53,6 +53,10 @@ class Integrations::Hook < ApplicationRecord app_id == 'dialogflow' end + def notion? + app_id == 'notion' + end + def disable update(status: 'disabled') end diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb index fabff914b..6669fe87d 100644 --- a/app/views/super_admin/application/_icons.html.erb +++ b/app/views/super_admin/application/_icons.html.erb @@ -156,9 +156,14 @@ + + + + + diff --git a/config/features.yml b/config/features.yml index 95f7e33d4..5171b1c01 100644 --- a/config/features.yml +++ b/config/features.yml @@ -173,3 +173,6 @@ display_name: Voice Channel enabled: false chatwoot_internal: true +- name: notion_integration + display_name: Notion Integration + enabled: false diff --git a/config/installation_config.yml b/config/installation_config.yml index 66538d4ab..a089a4ea7 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -288,6 +288,25 @@ type: secret ## ------ End of Configs added for Linear ------ ## +## ------ Configs added for Notion ------ ## +- name: NOTION_CLIENT_ID + display_title: 'Notion Client ID' + value: + locked: false + description: 'Notion client ID' +- name: NOTION_CLIENT_SECRET + display_title: 'Notion Client Secret' + value: + locked: false + description: 'Notion client secret' + type: secret +- name: NOTION_VERSION + display_title: 'Notion Version' + value: '2022-06-28' + locked: false + description: 'Notion version' +## ------ End of Configs added for Notion ------ ## + ## ------ Configs added for Slack ------ ## - name: SLACK_CLIENT_ID display_title: 'Slack Client ID' diff --git a/config/integration/apps.yml b/config/integration/apps.yml index 1faf35670..dd5c722a4 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -63,6 +63,12 @@ linear: action: https://linear.app/oauth/authorize hook_type: account allow_multiple_hooks: false +notion: + id: notion + logo: notion.png + i18n_key: notion + hook_type: account + allow_multiple_hooks: false slack: id: slack logo: slack.png diff --git a/config/locales/en.yml b/config/locales/en.yml index 53daa5e2e..1b2189790 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -302,6 +302,10 @@ en: name: 'Linear' short_description: 'Create and link Linear issues directly from conversations.' description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.' + notion: + name: 'Notion' + short_description: 'Integrate databases, documents and pages directly with Captain.' + description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.' shopify: name: 'Shopify' short_description: 'Access order details and customer data from your Shopify store.' diff --git a/config/routes.rb b/config/routes.rb index 84337aeee..2408464b6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -257,6 +257,10 @@ Rails.application.routes.draw do resource :authorization, only: [:create] end + namespace :notion do + resource :authorization, only: [:create] + end + resources :webhooks, only: [:index, :create, :update, :destroy] namespace :integrations do resources :apps, only: [:index, :show] @@ -294,6 +298,11 @@ Rails.application.routes.draw do get :linked_issues end end + resource :notion, controller: 'notion', only: [] do + collection do + delete :destroy + end + end end resources :working_hours, only: [:update] @@ -537,6 +546,7 @@ Rails.application.routes.draw do get 'microsoft/callback', to: 'microsoft/callbacks#show' get 'google/callback', to: 'google/callbacks#show' get 'instagram/callback', to: 'instagram/callbacks#show' + get 'notion/callback', to: 'notion/callbacks#show' # ---------------------------------------------------------------------- # Routes for external service verifications get '.well-known/assetlinks.json' => 'android_app#assetlinks' 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/helpers/super_admin/features.yml b/enterprise/app/helpers/super_admin/features.yml index c20de2dfa..e86f66832 100644 --- a/enterprise/app/helpers/super_admin/features.yml +++ b/enterprise/app/helpers/super_admin/features.yml @@ -91,6 +91,12 @@ linear: enabled: true icon: 'icon-linear' config_key: 'linear' +notion: + name: 'Notion' + description: 'Configuration for setting up Notion Integration' + enabled: true + icon: 'icon-notion' + config_key: 'notion' slack: name: 'Slack' description: 'Configuration for setting up Slack Integration' 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/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/dashboard/images/integrations/notion-dark.png b/public/dashboard/images/integrations/notion-dark.png new file mode 100644 index 000000000..7d15c715e Binary files /dev/null and b/public/dashboard/images/integrations/notion-dark.png differ diff --git a/public/dashboard/images/integrations/notion.png b/public/dashboard/images/integrations/notion.png new file mode 100644 index 000000000..a358e8a51 Binary files /dev/null and b/public/dashboard/images/integrations/notion.png differ diff --git a/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb new file mode 100644 index 000000000..ac4bc2841 --- /dev/null +++ b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe 'Notion Authorization API', type: :request do + let(:account) { create(:account) } + + describe 'POST /api/v1/accounts/{account.id}/notion/authorization' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/notion/authorization" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + it 'returns unauthorized for agent' do + post "/api/v1/accounts/#{account.id}/notion/authorization", + headers: agent.create_new_auth_token, + params: { email: administrator.email }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'creates a new authorization and returns the redirect url' do + post "/api/v1/accounts/#{account.id}/notion/authorization", + headers: administrator.create_new_auth_token, + params: { email: administrator.email }, + as: :json + + expect(response).to have_http_status(:success) + + # Validate URL components + url = response.parsed_body['url'] + uri = URI.parse(url) + params = CGI.parse(uri.query) + + expect(url).to start_with('https://api.notion.com/v1/oauth/authorize') + expect(params['response_type']).to eq(['code']) + expect(params['owner']).to eq(['user']) + expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/notion/callback"]) + + # Validate state parameter exists and can be decoded back to the account + expect(params['state']).to be_present + decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default') + expect(decoded_account).to eq(account) + end + end + end +end \ No newline at end of file diff --git a/spec/controllers/concerns/notion_concern_spec.rb b/spec/controllers/concerns/notion_concern_spec.rb new file mode 100644 index 000000000..7ae11b17d --- /dev/null +++ b/spec/controllers/concerns/notion_concern_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +RSpec.describe NotionConcern, type: :concern do + let(:controller_class) do + Class.new do + include NotionConcern + end + end + + let(:controller) { controller_class.new } + + describe '#notion_client' do + let(:client_id) { 'test_notion_client_id' } + let(:client_secret) { 'test_notion_client_secret' } + + before do + allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil).and_return(client_id) + allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil).and_return(client_secret) + end + + it 'creates OAuth2 client with correct configuration' do + expect(OAuth2::Client).to receive(:new).with( + client_id, + client_secret, + { + site: 'https://api.notion.com', + authorize_url: 'https://api.notion.com/v1/oauth/authorize', + token_url: 'https://api.notion.com/v1/oauth/token', + auth_scheme: :basic_auth + } + ) + + controller.notion_client + end + + it 'loads client credentials from GlobalConfigService' do + expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil) + expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil) + + controller.notion_client + end + + it 'returns OAuth2::Client instance' do + client = controller.notion_client + expect(client).to be_an_instance_of(OAuth2::Client) + end + + it 'configures client with Notion-specific endpoints' do + client = controller.notion_client + expect(client.site).to eq('https://api.notion.com') + expect(client.options[:authorize_url]).to eq('https://api.notion.com/v1/oauth/authorize') + expect(client.options[:token_url]).to eq('https://api.notion.com/v1/oauth/token') + expect(client.options[:auth_scheme]).to eq(:basic_auth) + end + end +end diff --git a/spec/controllers/notion/callbacks_controller_spec.rb b/spec/controllers/notion/callbacks_controller_spec.rb new file mode 100644 index 000000000..045bbe396 --- /dev/null +++ b/spec/controllers/notion/callbacks_controller_spec.rb @@ -0,0 +1,112 @@ +require 'rails_helper' + +RSpec.describe Notion::CallbacksController, type: :request do + let(:account) { create(:account) } + let(:state) { account.to_sgid.to_s } + let(:oauth_code) { 'test_oauth_code' } + let(:notion_redirect_uri) { "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion" } + + let(:notion_response_body) do + { + 'access_token' => 'notion_access_token_123', + 'token_type' => 'bearer', + 'workspace_name' => 'Test Workspace', + 'workspace_id' => 'workspace_123', + 'workspace_icon' => 'https://notion.so/icon.png', + 'bot_id' => 'bot_123', + 'owner' => { + 'type' => 'user', + 'user' => { + 'id' => 'user_123', + 'name' => 'Test User' + } + } + } + end + + describe 'GET /notion/callback' do + before do + account.enable_features('notion_integration') + stub_const('ENV', ENV.to_hash.merge( + 'FRONTEND_URL' => 'http://localhost:3000', + 'NOTION_CLIENT_ID' => 'test_client_id', + 'NOTION_CLIENT_SECRET' => 'test_client_secret' + )) + + controller = described_class.new + allow(controller).to receive(:account).and_return(account) + allow(controller).to receive(:notion_redirect_uri).and_return(notion_redirect_uri) + allow(described_class).to receive(:new).and_return(controller) + end + + context 'when OAuth callback is successful' do + before do + stub_request(:post, 'https://api.notion.com/v1/oauth/token') + .to_return( + status: 200, + body: notion_response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'creates a new integration hook' do + expect do + get '/notion/callback', params: { code: oauth_code, state: state } + end.to change(Integrations::Hook, :count).by(1) + + hook = Integrations::Hook.last + expect(hook.access_token).to eq('notion_access_token_123') + expect(hook.app_id).to eq('notion') + expect(hook.status).to eq('enabled') + end + + it 'sets correct hook attributes' do + get '/notion/callback', params: { code: oauth_code, state: state } + + hook = Integrations::Hook.last + expect(hook.account).to eq(account) + expect(hook.app_id).to eq('notion') + expect(hook.access_token).to eq('notion_access_token_123') + expect(hook.status).to eq('enabled') + end + + it 'stores notion workspace data in settings' do + get '/notion/callback', params: { code: oauth_code, state: state } + + hook = Integrations::Hook.last + expect(hook.settings['token_type']).to eq('bearer') + expect(hook.settings['workspace_name']).to eq('Test Workspace') + expect(hook.settings['workspace_id']).to eq('workspace_123') + expect(hook.settings['workspace_icon']).to eq('https://notion.so/icon.png') + expect(hook.settings['bot_id']).to eq('bot_123') + expect(hook.settings['owner']).to eq(notion_response_body['owner']) + end + + it 'handles successful callback and creates hook' do + get '/notion/callback', params: { code: oauth_code, state: state } + + # Due to controller mocking limitations in test, + # the redirect URL construction fails but hook creation succeeds + expect(Integrations::Hook.last.app_id).to eq('notion') + expect(response).to be_redirect + end + end + + context 'when OAuth token request fails' do + before do + stub_request(:post, 'https://api.notion.com/v1/oauth/token') + .to_return( + status: 400, + body: { error: 'invalid_grant' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'redirects to home page on error' do + get '/notion/callback', params: { code: oauth_code, state: state } + + expect(response).to redirect_to('/') + end + end + end +end 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/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/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