From 0331815cc5a5faede2ca1abdbbe433cc57988124 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 25 Jul 2024 14:24:04 -0700 Subject: [PATCH] feat: Integration with Captain (alpha) (#9834) - Integration with captain (alpha) Co-authored-by: Pranav --- .env.example | 2 + .../accounts/integrations/apps_controller.rb | 2 +- app/jobs/hook_job.rb | 8 +++ app/models/inbox.rb | 11 ++- app/models/integrations/app.rb | 6 +- config/features.yml | 2 + config/integration/apps.yml | 53 +++++++++++++- config/locales/en.yml | 3 + lib/integrations/captain/processor_service.rb | 65 ++++++++++++++++++ .../dialogflow/processor_service.rb | 4 +- .../images/integrations/captain-dark.png | Bin 0 -> 2492 bytes .../dashboard/images/integrations/captain.png | Bin 0 -> 2496 bytes .../integrations/apps_controller_spec.rb | 4 +- spec/models/integrations/app_spec.rb | 16 ++--- 14 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 lib/integrations/captain/processor_service.rb create mode 100644 public/dashboard/images/integrations/captain-dark.png create mode 100644 public/dashboard/images/integrations/captain.png 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 0000000000000000000000000000000000000000..edf8029d94d0adbe6060a3f5efc9e21a0480f9ba GIT binary patch literal 2492 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 GO#2&M{8zvL literal 0 HcmV?d00001 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