From b30ecb27a645242e6dbad26dd667083d6d71098b Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 5 May 2021 21:06:11 +0530 Subject: [PATCH] feat: Add APIs for Dialogflow integration V1 (#2155) Co-authored-by: Muhsin Keloth Co-authored-by: Pranav Raj S --- .rubocop.yml | 1 + Gemfile | 2 + Gemfile.lock | 37 ++++++++-- app/jobs/hook_job.rb | 26 ++++++- app/listeners/hook_listener.rb | 11 ++- app/models/conversation.rb | 2 +- app/models/integrations/app.rb | 2 + app/models/integrations/hook.rb | 2 +- config/integration/apps.yml | 5 ++ config/locales/en.yml | 3 + ...convert_integration_hook_settings_field.rb | 6 ++ db/schema.rb | 2 +- .../dialogflow/processor_service.rb | 69 ++++++++++++++++++ .../images/integrations/dialogflow.svg | 15 ++++ spec/factories/integrations/hooks.rb | 10 +-- spec/jobs/hook_job_spec.rb | 31 +++++++- .../dialogflow/processor_service_spec.rb | 73 +++++++++++++++++++ spec/listeners/hook_listener_spec.rb | 23 +++++- spec/models/conversation_spec.rb | 9 +++ 19 files changed, 303 insertions(+), 26 deletions(-) create mode 100644 db/migrate/20210425093724_convert_integration_hook_settings_field.rb create mode 100644 lib/integrations/dialogflow/processor_service.rb create mode 100644 public/dashboard/images/integrations/dialogflow.svg create mode 100644 spec/lib/integrations/dialogflow/processor_service_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 0fca6f54b..668951de2 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -101,6 +101,7 @@ Rails/BulkChangeTable: - 'db/migrate/20170511134418_latlong.rb' - 'db/migrate/20191027054756_create_contact_inboxes.rb' - 'db/migrate/20191130164019_add_template_type_to_messages.rb' + - 'db/migrate/20210425093724_convert_integration_hook_settings_field.rb' Rails/UniqueValidationWithoutIndex: Exclude: - 'app/models/channel/twitter_profile.rb' diff --git a/Gemfile b/Gemfile index b8d6d5626..409e5d493 100644 --- a/Gemfile +++ b/Gemfile @@ -80,6 +80,8 @@ gem 'twitty' gem 'koala' # slack client gem 'slack-ruby-client' +# for dialogflow integrations +gem 'google-cloud-dialogflow' ##--- gems for debugging and error reporting ---## # static analysis diff --git a/Gemfile.lock b/Gemfile.lock index 4c9b1c465..33b4a752b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -211,6 +211,12 @@ GEM fugit (1.4.1) et-orbi (~> 1.1, >= 1.1.8) raabro (~> 1.4) + gapic-common (0.3.4) + google-protobuf (~> 3.12, >= 3.12.2) + googleapis-common-protos (>= 1.3.9, < 2.0) + googleapis-common-protos-types (>= 1.0.4, < 2.0) + googleauth (~> 0.9) + grpc (~> 1.25) geocoder (1.6.3) gli (2.19.2) globalid (0.4.2) @@ -223,12 +229,18 @@ GEM representable (~> 3.0) retriable (>= 2.0, < 4.0) signet (~> 0.12) - google-cloud-core (1.5.0) + google-cloud-core (1.6.0) google-cloud-env (~> 1.0) google-cloud-errors (~> 1.0) - google-cloud-env (1.3.3) + google-cloud-dialogflow (1.2.0) + google-cloud-core (~> 1.5) + google-cloud-dialogflow-v2 (~> 0.1) + google-cloud-dialogflow-v2 (0.6.4) + gapic-common (~> 0.3) + google-cloud-errors (~> 1.0) + google-cloud-env (1.5.0) faraday (>= 0.17.3, < 2.0) - google-cloud-errors (1.0.1) + google-cloud-errors (1.1.0) google-cloud-storage (1.28.0) addressable (~> 2.5) digest-crc (~> 0.4) @@ -236,7 +248,14 @@ GEM google-cloud-core (~> 1.2) googleauth (~> 0.9) mini_mime (~> 1.0) - googleauth (0.13.1) + google-protobuf (3.15.8) + googleapis-common-protos (1.3.11) + google-protobuf (~> 3.14) + googleapis-common-protos-types (>= 1.0.6, < 2.0) + grpc (~> 1.27) + googleapis-common-protos-types (1.0.6) + google-protobuf (~> 3.14) + googleauth (0.16.2) faraday (>= 0.17.3, < 2.0) jwt (>= 1.4, < 3.0) memoist (~> 0.16) @@ -245,6 +264,9 @@ GEM signet (~> 0.14) groupdate (5.1.0) activesupport (>= 5) + grpc (1.37.1) + google-protobuf (~> 3.15) + googleapis-common-protos-types (~> 1.0) haikunator (1.1.0) hairtrigger (0.2.23) activerecord (>= 5.0, < 7) @@ -273,7 +295,7 @@ GEM railties (>= 4.2.0) thor (>= 0.14, < 2.0) json (2.3.1) - jwt (2.2.2) + jwt (2.2.3) kaminari (1.2.1) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.1) @@ -339,7 +361,7 @@ GEM method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (4.0.5) + public_suffix (4.0.6) puma (4.3.6) nio4r (~> 2.0) pundit (2.1.0) @@ -489,7 +511,7 @@ GEM sidekiq-cron (1.2.0) fugit (~> 1.1) sidekiq (>= 4.2.1) - signet (0.14.0) + signet (0.15.0) addressable (~> 2.3) faraday (>= 0.17.3, < 2.0) jwt (>= 1.5, < 3.0) @@ -612,6 +634,7 @@ DEPENDENCIES flag_shih_tzu foreman geocoder + google-cloud-dialogflow google-cloud-storage groupdate haikunator diff --git a/app/jobs/hook_job.rb b/app/jobs/hook_job.rb index 6fc8934d2..46d10c464 100644 --- a/app/jobs/hook_job.rb +++ b/app/jobs/hook_job.rb @@ -1,11 +1,29 @@ class HookJob < ApplicationJob queue_as :integrations - def perform(hook, message) - return unless hook.slack? - - Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform + def perform(hook, event_name, event_data = {}) + case hook.app_id + when 'slack' + process_slack_integration(hook, event_name, event_data) + when 'dialogflow' + process_dialogflow_integration(hook, event_name, event_data) + end rescue StandardError => e Raven.capture_exception(e) end + + private + + def process_slack_integration(hook, event_name, event_data) + return unless ['message.created'].include?(event_name) + + message = event_data[:message] + Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform + end + + def process_dialogflow_integration(hook, event_name, event_data) + return unless ['message.created', 'message.updated'].include?(event_name) + + Integrations::Dialogflow::ProcessorService.new(event_name: event_name, hook: hook, event_data: event_data).perform + end end diff --git a/app/listeners/hook_listener.rb b/app/listeners/hook_listener.rb index efe1c74e8..a98de860a 100644 --- a/app/listeners/hook_listener.rb +++ b/app/listeners/hook_listener.rb @@ -4,7 +4,16 @@ class HookListener < BaseListener return unless message.reportable? message.account.hooks.each do |hook| - HookJob.perform_later(hook, message) + HookJob.perform_later(hook, event.name, message: message) + end + end + + def message_updated(event) + message = extract_message_and_account(event)[0] + return unless message.reportable? + + message.account.hooks.each do |hook| + HookJob.perform_later(hook, event.name, message: message) end end end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 3ac8b0d6c..613e2a1c1 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -142,7 +142,7 @@ class Conversation < ApplicationRecord end def set_bot_conversation - self.status = :bot if inbox.agent_bot_inbox&.active? + self.status = :bot if inbox.agent_bot_inbox&.active? || inbox.hooks.pluck(:app_id).include?('dialogflow') end def notify_conversation_creation diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb index 7861cc5c8..bae1ca02f 100644 --- a/app/models/integrations/app.rb +++ b/app/models/integrations/app.rb @@ -38,6 +38,8 @@ class Integrations::App case params[:id] when 'slack' ENV['SLACK_CLIENT_SECRET'].present? + when 'dialogflow' + false else true end diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index 2df57ec2a..ad3184484 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -5,7 +5,7 @@ # id :bigint not null, primary key # access_token :string # hook_type :integer default("account") -# settings :text +# settings :jsonb # status :integer default("disabled") # created_at :datetime not null # updated_at :datetime not null diff --git a/config/integration/apps.yml b/config/integration/apps.yml index d58254bde..dd8f1be4c 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -8,3 +8,8 @@ webhooks: logo: cable.svg i18n_key: webhooks action: /webhook +dialogflow: + id: dialogflow + logo: dialogflow.svg + i18n_key: dialogflow + action: /dialogflow diff --git a/config/locales/en.yml b/config/locales/en.yml index b0aa0ef53..ecea2c545 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -93,3 +93,6 @@ en: webhooks: name: "Webhooks" description: "Webhook events provide you the realtime information about what's happening in your account. You can make use of the webhooks to communicate the events to your favourite apps like Slack or Github. Click on Configure to set up your webhooks." + dialogflow: + name: "Dialogflow" + description: "Connect your Dialogflow bot to your inbox. Let the bots handle the queries before handing it off to the customer service agent." diff --git a/db/migrate/20210425093724_convert_integration_hook_settings_field.rb b/db/migrate/20210425093724_convert_integration_hook_settings_field.rb new file mode 100644 index 000000000..3ba021291 --- /dev/null +++ b/db/migrate/20210425093724_convert_integration_hook_settings_field.rb @@ -0,0 +1,6 @@ +class ConvertIntegrationHookSettingsField < ActiveRecord::Migration[6.0] + def change + remove_column :integrations_hooks, :settings, :text + add_column :integrations_hooks, :settings, :jsonb, default: {} + end +end diff --git a/db/schema.rb b/db/schema.rb index 6dd7b6380..553570303 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -338,12 +338,12 @@ ActiveRecord::Schema.define(version: 2021_04_30_100138) do t.integer "inbox_id" t.integer "account_id" t.string "app_id" - t.text "settings" t.integer "hook_type", default: 0 t.string "reference_id" t.string "access_token" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false + t.jsonb "settings", default: {} end create_table "kbase_articles", force: :cascade do |t| diff --git a/lib/integrations/dialogflow/processor_service.rb b/lib/integrations/dialogflow/processor_service.rb new file mode 100644 index 000000000..cb75bc78f --- /dev/null +++ b/lib/integrations/dialogflow/processor_service.rb @@ -0,0 +1,69 @@ +class Integrations::Dialogflow::ProcessorService + pattr_initialize [:event_name!, :hook!, :event_data!] + + def perform + message = event_data[:message] + return if message.private? + return unless processable_message?(message) + return unless message.conversation.bot? + + response = get_dialogflow_response(message.conversation.contact_inbox.source_id, message_content(message)) + process_response(message, response) + end + + private + + def message_content(message) + return message.content_attributes['submitted_values'].first['value'] if event_name == 'message.updated' + + message.content + end + + def processable_message?(message) + return unless message.reportable? + return if message.outgoing? && !processable_outgoing_message?(message) + + true + end + + def processable_outgoing_message?(message) + event_name == 'message.updated' && ['input_select'].include?(message.content_type) + end + + def get_dialogflow_response(session_id, message) + Google::Cloud::Dialogflow.configure { |config| config.credentials = hook.settings['credentials'] } + session_client = Google::Cloud::Dialogflow.sessions + session = session_client.session_path project: hook.settings['project_id'], session: session_id + query_input = { text: { text: message, language_code: 'en-US' } } + session_client.detect_intent session: session, query_input: query_input + end + + def process_response(message, response) + text_response = response.query_result['fulfillment_text'] + + content_params = { content: text_response } if text_response.present? + content_params ||= response.query_result['fulfillment_messages'].first['payload'].to_h + + process_action(message, content_params['action']) and return if content_params['action'].present? + + create_conversation(message, content_params) + 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 process_action(message, action) + case action + when 'handoff' + message.conversation.open! + end + end +end diff --git a/public/dashboard/images/integrations/dialogflow.svg b/public/dashboard/images/integrations/dialogflow.svg new file mode 100644 index 000000000..32c657f3b --- /dev/null +++ b/public/dashboard/images/integrations/dialogflow.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spec/factories/integrations/hooks.rb b/spec/factories/integrations/hooks.rb index 6af74af9c..bd142246f 100644 --- a/spec/factories/integrations/hooks.rb +++ b/spec/factories/integrations/hooks.rb @@ -1,11 +1,11 @@ FactoryBot.define do factory :integrations_hook, class: 'Integrations::Hook' do - status { 1 } - inbox_id { 1 } - account_id { 1 } + status { Integrations::Hook.statuses['enabled'] } + inbox + account app_id { 'slack' } - settings { 'MyText' } - hook_type { 1 } + settings { { 'test': 'test' } } + hook_type { Integrations::Hook.statuses['account'] } access_token { SecureRandom.hex } reference_id { SecureRandom.hex } end diff --git a/spec/jobs/hook_job_spec.rb b/spec/jobs/hook_job_spec.rb index b852db7ad..c48d862d0 100644 --- a/spec/jobs/hook_job_spec.rb +++ b/spec/jobs/hook_job_spec.rb @@ -1,15 +1,38 @@ require 'rails_helper' RSpec.describe HookJob, type: :job do - subject(:job) { described_class.perform_later(hook, message) } + subject(:job) { described_class.perform_later(hook, event_name, event_data) } let(:account) { create(:account) } let(:hook) { create(:integrations_hook, account: account) } - let(:message) { create(:message) } + let(:event_name) { 'message.created' } + let(:event_data) { { message: create(:message, account: account) } } - it 'queues the job' do + it 'enqueues the job' do expect { job }.to have_enqueued_job(described_class) - .with(hook, message) + .with(hook, event_name, event_data) .on_queue('integrations') end + + context 'when handleable events like message.created' do + let(:process_service) { double } + + before do + allow(process_service).to receive(:perform) + end + + it 'calls Integrations::Slack::SendOnSlackService when its a slack hook' do + hook = create(:integrations_hook, app_id: 'slack', account: account) + allow(Integrations::Slack::SendOnSlackService).to receive(:new).and_return(process_service) + expect(Integrations::Slack::SendOnSlackService).to receive(:new) + described_class.perform_now(hook, event_name, event_data) + end + + it 'calls Integrations::Dialogflow::ProcessorService when its a dialogflow intergation' do + hook = create(:integrations_hook, app_id: 'dialogflow', account: account) + allow(Integrations::Dialogflow::ProcessorService).to receive(:new).and_return(process_service) + expect(Integrations::Dialogflow::ProcessorService).to receive(:new) + described_class.perform_now(hook, event_name, event_data) + end + end end diff --git a/spec/lib/integrations/dialogflow/processor_service_spec.rb b/spec/lib/integrations/dialogflow/processor_service_spec.rb new file mode 100644 index 000000000..7f70e315e --- /dev/null +++ b/spec/lib/integrations/dialogflow/processor_service_spec.rb @@ -0,0 +1,73 @@ +require 'rails_helper' + +describe Integrations::Dialogflow::ProcessorService do + let(:account) { create(:account) } + let(:hook) { create(:integrations_hook, app_id: 'dialogflow', account: account) } + let(:conversation) { create(:conversation, account: account, status: :bot) } + let(:message) { create(:message, account: account, conversation: conversation) } + let(:event_name) { 'message.created' } + let(:event_data) { { message: message } } + + describe '#perform' do + let(:dialogflow_service) { double } + let(:dialogflow_response) do + ActiveSupport::HashWithIndifferentAccess.new( + fulfillment_text: 'hello' + ) + end + + let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) } + + before do + allow(dialogflow_service).to receive(:query_result).and_return(dialogflow_response) + allow(processor).to receive(:get_dialogflow_response).and_return(dialogflow_service) + end + + context 'when valid message and dialogflow returns fullfillment text' do + it 'creates the response message' do + expect(processor.perform.content).to eql('hello') + end + end + + context 'when dialogflow returns fullfillment text to be empty' do + let(:dialogflow_response) do + ActiveSupport::HashWithIndifferentAccess.new( + fulfillment_messages: [{ payload: { content: 'hello payload' } }] + ) + end + + it 'creates the response message based on fulfillment messages' do + expect(processor.perform.content).to eql('hello payload') + end + end + + context 'when dialogflow returns action' do + let(:dialogflow_response) do + ActiveSupport::HashWithIndifferentAccess.new( + fulfillment_messages: [{ payload: { action: 'handoff' } }] + ) + end + + it 'handsoff the conversation to agent' do + processor.perform + expect(conversation.status).to eql('open') + end + end + + context 'when conversation is not bot' do + let(:conversation) { create(:conversation, account: account, status: :open) } + + it 'returns nil' do + expect(processor.perform).to be(nil) + end + end + + context 'when message is private' do + let(:message) { create(:message, account: account, conversation: conversation, private: true) } + + it 'returns nil' do + expect(processor.perform).to be(nil) + end + end + end +end diff --git a/spec/listeners/hook_listener_spec.rb b/spec/listeners/hook_listener_spec.rb index f22a074ab..4caa4dafd 100644 --- a/spec/listeners/hook_listener_spec.rb +++ b/spec/listeners/hook_listener_spec.rb @@ -12,7 +12,7 @@ describe HookListener do let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) } describe '#message_created' do - let(:event_name) { :'conversation.created' } + let(:event_name) { 'message.created' } context 'when hook is not configured' do it 'does not trigger hook job' do @@ -24,9 +24,28 @@ describe HookListener do context 'when hook is configured' do it 'triggers hook job' do hook = create(:integrations_hook, account: account) - expect(HookJob).to receive(:perform_later).with(hook, message).once + expect(HookJob).to receive(:perform_later).with(hook, 'message.created', message: message).once listener.message_created(event) end end end + + describe '#message_updated' do + let(:event_name) { 'message.updated' } + + context 'when hook is not configured' do + it 'does not trigger hook job' do + expect(HookJob).to receive(:perform_later).exactly(0).times + listener.message_updated(event) + end + end + + context 'when hook is configured' do + it 'triggers hook job' do + hook = create(:integrations_hook, account: account) + expect(HookJob).to receive(:perform_later).with(hook, 'message.updated', message: message).once + listener.message_updated(event) + end + end + end end diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 55067578e..6ae1337ee 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -350,6 +350,15 @@ RSpec.describe Conversation, type: :model do end end + describe '#botintegration: when conversation created in inbox with dialogflow integration' do + let(:hook) { create(:integrations_hook, app_id: 'dialogflow') } + let(:conversation) { create(:conversation, inbox: hook.inbox) } + + it 'returns conversation status as bot' do + expect(conversation.status).to eq('bot') + end + end + describe '#can_reply?' do describe 'on channels without 24 hour restriction' do let(:conversation) { create(:conversation) }