diff --git a/.env.example b/.env.example index befcde463..81228f00a 100644 --- a/.env.example +++ b/.env.example @@ -257,3 +257,5 @@ AZURE_APP_SECRET= # Set to true if you want to remove stale contact inboxes # contact_inboxes with no conversation older than 90 days will be removed # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false + +# CAPTAIN_API_URL=http://localhost:3001/api diff --git a/app/controllers/api/v1/accounts/integrations/apps_controller.rb b/app/controllers/api/v1/accounts/integrations/apps_controller.rb index 358a15d87..c2284bc2e 100644 --- a/app/controllers/api/v1/accounts/integrations/apps_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/apps_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseC private def fetch_apps - @apps = Integrations::App.all.select(&:active?) + @apps = Integrations::App.all.select { |app| app.active?(Current.account) } end def fetch_app diff --git a/app/jobs/hook_job.rb b/app/jobs/hook_job.rb index 29c6dccfe..1912fac01 100644 --- a/app/jobs/hook_job.rb +++ b/app/jobs/hook_job.rb @@ -9,6 +9,8 @@ class HookJob < ApplicationJob process_slack_integration(hook, event_name, event_data) when 'dialogflow' process_dialogflow_integration(hook, event_name, event_data) + when 'captain' + process_captain_integration(hook, event_name, event_data) when 'google_translate' google_translate_integration(hook, event_name, event_data) end @@ -35,6 +37,12 @@ class HookJob < ApplicationJob Integrations::Dialogflow::ProcessorService.new(event_name: event_name, hook: hook, event_data: event_data).perform end + def process_captain_integration(hook, event_name, event_data) + return unless ['message.created'].include?(event_name) + + Integrations::Captain::ProcessorService.new(event_name: event_data, hook: hook, event_data: event_data).perform + end + def google_translate_integration(hook, event_name, event_data) return unless ['message.created'].include?(event_name) diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 17f4fc045..8158ab733 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -129,7 +129,16 @@ class Inbox < ApplicationRecord end def active_bot? - agent_bot_inbox&.active? || hooks.where(app_id: 'dialogflow', status: 'enabled').count.positive? + agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow], + status: 'enabled').count.positive? || captain_enabled? + end + + def captain_enabled? + captain_hook = account.hooks.where( + app_id: %w[captain], status: 'enabled' + ).first + + captain_hook.present? && captain_hook.settings['inbox_ids'].split(',').include?(id.to_s) end def inbox_type diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb index 10e221a80..9629bdd84 100644 --- a/app/models/integrations/app.rb +++ b/app/models/integrations/app.rb @@ -34,12 +34,14 @@ class Integrations::App end end - def active? + def active?(account) case params[:id] when 'slack' ENV['SLACK_CLIENT_SECRET'].present? when 'linear' - Current.account.feature_enabled?('linear_integration') + account.feature_enabled?('linear_integration') + when 'captain' + account.feature_enabled?('captain_integration') && ENV['CAPTAIN_API_URL'].present? else true end diff --git a/config/features.yml b/config/features.yml index 07552a238..42cf3460d 100644 --- a/config/features.yml +++ b/config/features.yml @@ -87,3 +87,5 @@ premium: true - name: linear_integration enabled: false +- name: captain_integration + enabled: false diff --git a/config/integration/apps.yml b/config/integration/apps.yml index cf6730aec..24c19595c 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -8,7 +8,58 @@ # settings_json_schema: the json schema used to validate the settings hash (https://json-schema.org/) # settings_form_schema: the formulate schema used in frontend to render settings form (https://vueformulate.com/) ######################################################## - +captain: + id: captain + logo: captain.png + i18n_key: captain + action: /captain + hook_type: account + allow_multiple_hooks: false + settings_json_schema: { + "type": "object", + "properties": { + "access_token": { "type": "string" }, + "account_id": { "type": "string" }, + "account_email": { "type": "string" }, + "assistant_id": { "type": "string" }, + "inbox_ids": { "type": "strings" }, + }, + "required": ["access_token", "account_id", "account_email", "assistant_id"], + "additionalProperties": false, + } + settings_form_schema: [ + { + "label": "Access Token", + "type": "text", + "name": "access_token", + "validation": "required", + }, + { + "label": "Account ID", + "type": "text", + "name": "account_id", + "validation": "required", + }, + { + "label": "Account Email", + "type": "text", + "name": "account_email", + "validation": "required", + }, + { + "label": "Assistant Id", + "type": "text", + "name": "assistant_id", + "validation": "required", + }, + { + "label": "Inbox Ids", + "type": "text", + "name": "inbox_ids", + "validation": "", + }, + ] + visible_properties: [] webhooks: id: webhook logo: webhooks.png diff --git a/config/locales/en.yml b/config/locales/en.yml index 802ed529d..63593ef04 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -227,6 +227,9 @@ en: linear: name: "Linear" description: "Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process." + captain: + name: "Captain" + description: "Captain is a native AI assistant built for your product and trained on your company's knowledge base. It responds like a human and resolves customer queries effectively. Configure it to your inboxes easily." public_portal: search: search_placeholder: Search for article by title or body... diff --git a/lib/integrations/captain/processor_service.rb b/lib/integrations/captain/processor_service.rb new file mode 100644 index 000000000..20aa202fa --- /dev/null +++ b/lib/integrations/captain/processor_service.rb @@ -0,0 +1,65 @@ +class Integrations::Captain::ProcessorService < Integrations::BotProcessorService + pattr_initialize [:event_name!, :hook!, :event_data!] + + private + + def get_response(_session_id, message_content) + call_captain(message_content) + end + + def process_response(message, response) + if response == 'conversation_handoff' + message.conversation.bot_handoff! + else + create_conversation(message, { content: response }) + end + end + + def create_conversation(message, content_params) + return if content_params.blank? + + conversation = message.conversation + conversation.messages.create!( + content_params.merge( + { + message_type: :outgoing, + account_id: conversation.account_id, + inbox_id: conversation.inbox_id + } + ) + ) + end + + def call_captain(message_content) + url = "#{ENV.fetch('CAPTAIN_API_URL', nil)}/accounts/#{hook.settings['account_id']}/assistants/#{hook.settings['assistant_id']}/chat" + + headers = { + 'X-USER-EMAIL' => hook.settings['account_email'], + 'X-USER-TOKEN' => hook.settings['access_token'], + 'Content-Type' => 'application/json' + } + + body = { + message: message_content, + previous_messages: previous_messages + } + + response = HTTParty.post(url, headers: headers, body: body.to_json) + response.parsed_response['message'] + end + + def previous_messages + previous_messages = [] + conversation.messages.where(message_type: [:outgoing, :incoming]).where(private: false).offset(1).find_each do |message| + next if message.content_type != 'text' + + role = determine_role(message) + previous_messages << { message: message.content, type: role } + end + previous_messages + end + + def determine_role(message) + message.message_type == 'incoming' ? 'User' : 'Bot' + end +end diff --git a/lib/integrations/dialogflow/processor_service.rb b/lib/integrations/dialogflow/processor_service.rb index 103ef05c5..f3962a66c 100644 --- a/lib/integrations/dialogflow/processor_service.rb +++ b/lib/integrations/dialogflow/processor_service.rb @@ -14,13 +14,13 @@ class Integrations::Dialogflow::ProcessorService < Integrations::BotProcessorSer message.content end - def get_response(session_id, message) + def get_response(session_id, message_content) if hook.settings['credentials'].blank? Rails.logger.warn "Account: #{hook.try(:account_id)} Hook: #{hook.id} credentials are not present." && return end configure_dialogflow_client_defaults - detect_intent(session_id, message) + detect_intent(session_id, message_content) rescue Google::Cloud::PermissionDeniedError => e Rails.logger.warn "DialogFlow Error: (account-#{hook.try(:account_id)}, hook-#{hook.id}) #{e.message}" hook.prompt_reauthorization! diff --git a/public/dashboard/images/integrations/captain-dark.png b/public/dashboard/images/integrations/captain-dark.png new file mode 100644 index 000000000..edf8029d9 Binary files /dev/null and b/public/dashboard/images/integrations/captain-dark.png differ diff --git a/public/dashboard/images/integrations/captain.png b/public/dashboard/images/integrations/captain.png new file mode 100644 index 000000000..3141160f4 Binary files /dev/null and b/public/dashboard/images/integrations/captain.png differ diff --git a/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb index 43735046e..8662a5006 100644 --- a/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb @@ -16,7 +16,7 @@ RSpec.describe 'Integration Apps API', type: :request do let(:admin) { create(:user, account: account, role: :administrator) } it 'returns all active apps without sensitive information if the user is an agent' do - first_app = Integrations::App.all.find(&:active?) + first_app = Integrations::App.all.find { |app| app.active?(account) } get api_v1_account_integrations_apps_url(account), headers: agent.create_new_auth_token, as: :json @@ -41,7 +41,7 @@ RSpec.describe 'Integration Apps API', type: :request do end it 'returns all active apps with sensitive information if user is an admin' do - first_app = Integrations::App.all.find(&:active?) + first_app = Integrations::App.all.find { |app| app.active?(account) } get api_v1_account_integrations_apps_url(account), headers: admin.create_new_auth_token, as: :json diff --git a/spec/models/integrations/app_spec.rb b/spec/models/integrations/app_spec.rb index 703069349..b9652ff89 100644 --- a/spec/models/integrations/app_spec.rb +++ b/spec/models/integrations/app_spec.rb @@ -5,10 +5,6 @@ RSpec.describe Integrations::App do let(:app) { apps.find(id: app_name) } let(:account) { create(:account) } - before do - allow(Current).to receive(:account).and_return(account) - end - describe '#name' do let(:app_name) { 'slack' } @@ -28,6 +24,10 @@ RSpec.describe Integrations::App do describe '#action' do let(:app_name) { 'slack' } + before do + allow(Current).to receive(:account).and_return(account) + end + context 'when the app is slack' do it 'returns the action URL with client_id and redirect_uri' do with_modified_env SLACK_CLIENT_ID: 'dummy_client_id' do @@ -46,7 +46,7 @@ RSpec.describe Integrations::App do context 'when the app is slack' do it 'returns true if SLACK_CLIENT_SECRET is present' do with_modified_env SLACK_CLIENT_SECRET: 'random_secret' do - expect(app.active?).to be true + expect(app.active?(account)).to be true end end end @@ -55,14 +55,14 @@ RSpec.describe Integrations::App do let(:app_name) { 'linear' } it 'returns true if the linear integration feature is disabled' do - expect(app.active?).to be false + expect(app.active?(account)).to be false end it 'returns false if the linear integration feature is enabled' do account.enable_features('linear_integration') account.save! - expect(app.active?).to be true + expect(app.active?(account)).to be true end end @@ -70,7 +70,7 @@ RSpec.describe Integrations::App do let(:app_name) { 'webhook' } it 'returns true' do - expect(app.active?).to be true + expect(app.active?(account)).to be true end end end