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